diff --git a/docs/changes/unreleased/972-question-evidence-split.md b/docs/changes/unreleased/972-question-evidence-split.md new file mode 100644 index 0000000000..06fa2b9406 --- /dev/null +++ b/docs/changes/unreleased/972-question-evidence-split.md @@ -0,0 +1,29 @@ +--- +kind: changed +title: An answer's evidence stands beside the answers, and the page a question opens is two panes +pr: 972 +surface: [chat] +invalidates: + - "A question's panel drew no evidence at all — the diagram, the diff or the table an answer brought was reachable only through `o open full`. No longer true: where the answers carry blocks and the frame is a hundred columns or wider, the panel splits and the evidence of the answer the pointer is on stands beside the list; narrower, it unfolds under that answer's own row. `railSlimFloor` (100) is the one reading of `two columns fit`, for the task column and for a question alike." + - "The page `o` opens was one scrolling column of folding sections, one per answer, opened and folded with `→`/`←` and with `o` again. It is two panes now — the answers on the left, the evidence of the one you are standing on on the right — and one column only where the body is under a hundred cells. Nothing folds: the answer the pointer is on is the open one." + - "On that page `→` opened the answer you were on and `←` folded it. `→ detail` now hands the arrows to the evidence pane, `↑↓ scroll` moves it, and `← back to the answers` hands them back." + - "On that page a digit answered the question at once. It walks the pointer to that answer now and `enter` takes it; on the block above the box a digit still answers at once." + - "The page drew `‹ back` and named the asker's answer `my pick`. It draws neither: the recommendation wears `◆ recommended` on its own row in every view, and `esc` is the way out." + - "The asker's two-pane layout block went side by side above a hundred columns and stacked below. It decides by its content now — each pane's widest line fits its half, or the two stack — so a layout drawn in the pane beside a list is laid out on what it is rather than on how wide the window is." + - "A question put off drew its fold rule wherever the block stood, including under the page that was showing it. The page's own question no longer draws that rule: `o` folds what it opens, and `space open` under an open page was offering what was already open." + - "The tmux suite waited for `[o] open it`, a spelling the surface stopped using when the keys lost their brackets. The needle is `o open full`." + - "`what it showed you` — the attachments the asker brought for the whole decision — stood ABOVE the answers, which was itself a fix to an owner complaint that the evidence was buried under the list. It stands UNDER the answers now, in the left pane below the list, because the pane on the right is where an ANSWER's own evidence goes and the two would otherwise read as one column of attachments belonging to nothing in particular. `TestTheQuestionsOwnEvidenceIsOneTitledSectionAboveTheAnswers` carried the old position and is deleted; `questionroom_test.go` pins the new one." + - "The panel's split and the page each worked out the answer column's width and how wide the list wanted to be, and the two formulas disagreed: labels of 5 and 30 in a forty-cell list padded to 20 in one drawing and 5 in the other, and the panel reserved fourteen cells for `◆ recommended` on questions that have no recommendation. `questionLabelPad` and `app.questionListWant` are the one account of each, called from every drawing of the list." + - "The page's foot re-derived the seam its rule meets, and drew that rule at the FULL frame width while the body draws at `bodyWidth()` — so with the task column up on a 140-column terminal the page's top rule stopped at the body's edge and the foot's rule ran on underneath the rail. The foot reads the seam the body actually drew (`app.questionRoomSeam` is now a read, not a calculation) and draws at the body's width, and the screens table covers a split with the column UP, which every earlier shot stowed." + - "An open answer's label was `ordinary ink` everywhere it was drawn. In the EVIDENCE it is bold ink — the answer's own word is the pane's title, and the pane is the one drawing where that word is a heading rather than a row. It is still INK: #933's colour ruling puts the hue on the three marks alone, and weight is not hue. On a row — the panel's list, the page's list, a card — the label is plain ink, unchanged." + - "`questionPanelBody` — the door the task record's card and home's errand pane draw a question's answers through — briefly unfolded the FULL evidence of the answer under the pointer, every block included, on both of those surfaces. It draws none: both callers put these rows inside a card, and a card that grew by every diagram an answer brought would push the rest of its column off the screen with no way to say that it cut. The split and the unfold belong to the panel and the page, which own their own height." +--- + +A question is a decision handed to a person with its evidence attached, and the +evidence is per answer — so it is drawn where the answer is, at every size the +question has. One account of the case (`then ·`, `why this one ·`, `would switch +if`, `confidence ·`) and one renderer for the blocks now serve the panel's pane, +the panel's unfold, the page's pane and the page's one column; before this the +page spelled the labelled lines itself and the panel spelled its one-line case +itself, and the two disagreed about whether a model's `If you…` was joined onto +`would switch if` twice. diff --git a/docs/design/questions/DESIGN.md b/docs/design/questions/DESIGN.md index bf2e33a0f9..a8785d7064 100644 --- a/docs/design/questions/DESIGN.md +++ b/docs/design/questions/DESIGN.md @@ -181,9 +181,12 @@ never resolves a task by itself. - **line** — one row + one answers row, pinned above the box. Permission, confirmation, ratify. - **card** — head · reason/attribution · one row per option (word · consequence · pick mark; a long word wraps, never cut; a checklist's rows carry its ticks and a digit toggles) · answers row. Transcript, home strip, other window, room foot. Every option is a row: the engine's cap (4, 8 on a checklist) is the only bound. Keys are digits `1`… in order; `ask` renumbers whatever the model wrote and hands the answer back in the model's own names. The asker's `form: line` is honoured only for plain options with no structured input; width decides the rest. - **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): head and attribution, the question's own `Attach` as ONE dim-titled section (`what it showed you`) above the answers, options as sections (`▸`/`▾`, bodies, blocks), `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), `→` opens the answer it is on and `←` folds it, `enter` takes it, a digit answers at once; a click on an answer's ROW moves onto it and opens it, and a second click on that row is `enter` — one press to read, one to decide. **An open answer reads in three tiers**: the label 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`) — one blank row closing each open answer. +- **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. +**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. + Every form folds down (room → card → line → chip) and opens up (`enter`/`o`). The chip lives in the status line: ` 3 questions · ` and is reachable from every page; home lists every open question under its diff --git a/docs/design/questions/GALLERY.md b/docs/design/questions/GALLERY.md index 661424c69e..614f151ef2 100644 --- a/docs/design/questions/GALLERY.md +++ b/docs/design/questions/GALLERY.md @@ -20,7 +20,8 @@ the findings the suite is red on, and they are here for the same reason the rest are: what a lane owes next is easier to see than to describe. > **THESE PICTURES WERE TAKEN BEFORE THE OWNER'S RULINGS OF 2026-09-11 AND HAVE -> NOT BEEN RE-TAKEN.** What moved: every question now hangs in the one frame +> NOT BEEN RE-TAKEN** — except the eight under *The evidence beside the answers*, +> which are of the drawing those rulings asked for. What moved: every question now hangs in the one frame > (`internal/tui3/frame.go`), the question's violet is retired and its amber is > on the three marks only, the keys stand in two tiers, the receipt opens with > `✓` rather than the word `decided`, a permission's pointer is placed by the @@ -70,6 +71,9 @@ and when: `start it in 27s · your rule`.* ## The room +**The room below is the page as it was before 2026-09-11.** It is two panes now, and +the pictures of that are further down under *The evidence beside the answers*. + A page over the conversation, in the task-room idiom. The turn under it keeps streaming; `esc` restores the conversation with its scroll untouched. @@ -104,6 +108,58 @@ differently spelled. One of them is owed a deletion.* ![the answer reaches the asker](screens/the-room-compares-annotates-asks-back-and-sends-reply.png) *The model's next turn, with the pick and the typed words in the tool result behind it.* +## The evidence beside the answers, and the page as two panes + +**These eight are the only pictures in this folder taken after the owner's rulings of +2026-09-11**, and they are taken a different way: `~/af-qv-reports/R/drive.sh` opens +this worktree's own binary in a tmux session on a throwaway home with a fixture named +in the environment (`AFORGE_QUESTION_DEMO`, `internal/tui3/questiondemo.go`), so they +cost no model and can be re-taken in seconds. The marks that show as `▯` are the plain +glyph floor rendering in a font the renderer does not have; the terminal draws `?`, `◆` +and `✓`. + +![the evidence beside the answers](screens/the-evidence-stands-beside-the-answers-panel-140.png) +*The block above the box, at 140 columns (preview pick A). The answers keep the left, +and the evidence of the one the pointer is on — its word, what it means, `then ·`, +`why this one ·`, `would switch if`, `confidence ·`, and the diagram it drew — stands +beside them. Walking the pointer changes the right-hand side and nothing else.* + +![unfolded under its row](screens/the-evidence-stands-beside-the-answers-unfold-96.png) +*The same question at 96 columns (preview-narrow pick B). There is no room for two, so +the evidence unfolds under the answer's own row — and that row gives up its `then` line +to it rather than saying it twice. A panel never takes more than half the frame, so the +rest is cut on a row that says how much is left and the way to it: +`… 4 more lines · o open full`.* + +![the page as two panes](screens/the-page-is-two-panes-140.png) +*`o`, at 140 columns (page pick A). The same split with the frame's height: the rules +above and below meet the seam at `┬` and `┴`, the foot's first row says what `enter` +would send, and the second carries the page's own two tiers of keys in place of the +legend.* + +![the arrows move into the evidence](screens/the-page-is-two-panes-detail-140.png) +*After `→`. The foot swaps `↑↓ choose · … · → detail` for `↑↓ scroll · ← back to the +answers`: while the arrows are the pane's they move the pane, and the key that gives +them back is on the row. A key that is not drawn does nothing.* + +![one column](screens/the-page-is-two-panes-column-96.png) +*The page at 96 columns. One column, the answer the pointer is on unfolded under its +row, the others each keeping their row — the same lines as the pane, stacked.* + +![the phone](screens/the-page-is-two-panes-phone-56.png) +*And at 56. The head's attribution wraps to a row of its own, the second tier of keys is +given up from the right, and the evidence is still whole.* + +![the layout block](screens/the-page-is-two-panes-layout-140.png) +*A layout block on the page: the asker's two drawings side by side, which they are here +because each one's widest line fits its half — the block decides on its content now, +not on a width.* + +![the plain tier](screens/the-page-is-two-panes-ascii-110.png) +*110 columns on the light theme with the plain glyph floor: the rules are `-` runs, the +seam is a blank column, and the junctions are edge characters. A column of `|` down +every row is punctuation a screen reader says aloud on every line.* + ## Fill in the blanks ![the card first](screens/a-sentence-with-holes-is-filled-in-card.png) diff --git a/docs/design/questions/screens/the-evidence-stands-beside-the-answers-panel-140.ansi b/docs/design/questions/screens/the-evidence-stands-beside-the-answers-panel-140.ansi new file mode 100644 index 0000000000..ef5666d92d --- /dev/null +++ b/docs/design/questions/screens/the-evidence-stands-beside-the-answers-panel-140.ansi @@ -0,0 +1,32 @@ + aforge fri 5:52pm +  Home  new conversation ×  +  +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + · the engine on spark is an older aforge and is still holding work — it picks up this build the moment it goes quiet + + ┌─┐ ┌─ ┌─┐ ┌─┐ ┌─┐ ┌─┐ + ├─┤ ├─ │ │ │ └─┤ ├─╴ + └─┘ │ └─┘ │ └─┘ └─┘ + openai/gpt-4o-mini · custom crew + + ›  + try "what is in this folder" · /task starts work · / shows commands + +╭─  which store should the ledger sit on? ──────────────────────────────────────────────────────────────────────── aforge asks · waiting ─╮ +│ a schema change is next and it is cheaper before there are rows │ +│ │ │ +│  ▸ 1 postgres  recommended │ postgres │ +│ 2 sqlite beside the project │ Rows already carry a foreign key into it and the migration is one file. │ +│ 3 a file per day  │ + one place to back up │ +│ 4 something else…  │ - another service to run locally │ +│ │ │ +│  just this once · for this project │ then · the ledger and the rest of the project share one connection │ +│ │ why this one · because it is the only store the reporting job already reads │ +│ │ would switch if the ledger ever has to run on a machine with no server on it │ +│ │ confidence · fairly sure │ +│ │ │ +│ │ what it would look like │ +│ │  app --> pg --> report │ +╰─ ↑↓ choose · enter take it · esc later ──────────────────────────────────────────────────────────────────────────────────────────────────╯ + o open full · c change · ? ask back · d you decide · D decide these from now on · t how long · 1–3 jump +  which store should the ledger sit… · alt+a · idle diff --git a/docs/design/questions/screens/the-evidence-stands-beside-the-answers-panel-140.png b/docs/design/questions/screens/the-evidence-stands-beside-the-answers-panel-140.png new file mode 100644 index 0000000000..252c7b1121 Binary files /dev/null and b/docs/design/questions/screens/the-evidence-stands-beside-the-answers-panel-140.png differ diff --git a/docs/design/questions/screens/the-evidence-stands-beside-the-answers-unfold-96.ansi b/docs/design/questions/screens/the-evidence-stands-beside-the-answers-unfold-96.ansi new file mode 100644 index 0000000000..9e6619668a --- /dev/null +++ b/docs/design/questions/screens/the-evidence-stands-beside-the-answers-unfold-96.ansi @@ -0,0 +1,36 @@ + aforge fri 5:53pm +  Home  new conversation ×  +  +──────────────────────────────────────────────────────────────────────────────────────────────── + + · the engine on spark is an older aforge and is still holding work — it picks up this build +  the moment it goes quiet + + ┌─┐ ┌─ ┌─┐ ┌─┐ ┌─┐ ┌─┐ + ├─┤ ├─ │ │ │ └─┤ ├─╴ + └─┘ │ └─┘ │ └─┘ └─┘ + openai/gpt-4o-mini · custom crew + + ›  + try "what is in this folder" · /task starts work · / shows commands + +╭─  which store should the ledger sit on? ──────────────────────────── aforge asks · waiting ─╮ +│ a schema change is next and it is cheaper before there are rows │ +│ │ +│ ▸ 1 postgres  recommended │ +│ Rows already carry a foreign key into it and the migration is one file. │ +│ + one place to back up │ +│ - another service to run locally │ +│ │ +│ then · the ledger and the rest of the project share one connection │ +│ why this one · because it is the only store the reporting job already reads │ +│ would switch if the ledger ever has to run on a machine with no server on it │ +│ … 4 more lines · o open full │ +│ 2 sqlite beside the project the ledger travels with the checkout │ +│ 3 a file per day  the ledger is readable with cat and nothing else │ +│ 4 something else…  │ +│ │ +│  just this once · for this project │ +│ │ +╰─ ↑↓ choose · enter take it · esc later ──────────────────────────────────────────────────────╯ + o open full · c change · ? ask back · d you decide · t how long · 1–3 jump +  which store should the ledger sit… · alt+a · idle diff --git a/docs/design/questions/screens/the-evidence-stands-beside-the-answers-unfold-96.png b/docs/design/questions/screens/the-evidence-stands-beside-the-answers-unfold-96.png new file mode 100644 index 0000000000..de17261ac7 Binary files /dev/null and b/docs/design/questions/screens/the-evidence-stands-beside-the-answers-unfold-96.png differ diff --git a/docs/design/questions/screens/the-page-is-two-panes-140.ansi b/docs/design/questions/screens/the-page-is-two-panes-140.ansi new file mode 100644 index 0000000000..ed39de1b72 --- /dev/null +++ b/docs/design/questions/screens/the-page-is-two-panes-140.ansi @@ -0,0 +1,40 @@ + aforge fri 5:50pm +  Home  new conversation ×  +  +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + +  which store should the ledger sit on? aforge asks · the turn waits on it + a schema change is next and it is cheaper before there are rows + +───────────────────────────────────────────────────┬──────────────────────────────────────────────────────────────────────────────────────── +  ▸ 1 postgres   recommended │ postgres + 2 sqlite beside the project │ Rows already carry a foreign key into it and the migration is one file. + 3 a file per day │ + one place to back up + 4 something else… │ - another service to run locally + │ + │ then · the ledger and the rest of the project share one connection + │ why this one · because it is the only store the reporting job already reads + │ would switch if the ledger ever has to run on a machine with no server on it + │ confidence · fairly sure + │ + │ what it would look like + │  app --> pg --> report + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ +─ answering 1 postgres · just this once ───────────┴──────────────────────────────────────────────────────────────────────────────────────── + ↑↓ choose · enter take it · esc later · → detail c change · x compare · ? ask back · d you decide · t how long + ›  + idle diff --git a/docs/design/questions/screens/the-page-is-two-panes-140.png b/docs/design/questions/screens/the-page-is-two-panes-140.png new file mode 100644 index 0000000000..5e827b9afc Binary files /dev/null and b/docs/design/questions/screens/the-page-is-two-panes-140.png differ diff --git a/docs/design/questions/screens/the-page-is-two-panes-ascii-110.ansi b/docs/design/questions/screens/the-page-is-two-panes-ascii-110.ansi new file mode 100644 index 0000000000..428c9546bd --- /dev/null +++ b/docs/design/questions/screens/the-page-is-two-panes-ascii-110.ansi @@ -0,0 +1,29 @@ + aforge fri 5:51pm +  Home  new conversation ×  +  +────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + ? which store should the ledger sit on? aforge asks · the turn waits on it + a schema change is next and it is cheaper before there are rows + +-------------------------------------------------------------------------------------------------------------- +  ▸ 1 postgres  * recommended   postgres + 2 sqlite beside the project   Rows already carry a foreign key into it and the + 3 a file per day   migration is one file. + 4 something else…   + one place to back up +   - another service to run locally +   +   then · the ledger and the rest of the project share one +   connection +   why this one · because it is the only store the +   reporting job already reads +   would switch if the ledger ever has to run on a machine +   with no server on it +   confidence · fairly sure +   +   what it would look like +    app --> pg --> report +   +- answering 1 postgres · just this once ---------------------------------------------------------------------- + ↑↓ choose · enter take it · esc later · → detail x compare · ? ask back · d you decide · t how long + ›  + idle diff --git a/docs/design/questions/screens/the-page-is-two-panes-ascii-110.png b/docs/design/questions/screens/the-page-is-two-panes-ascii-110.png new file mode 100644 index 0000000000..f34c128937 Binary files /dev/null and b/docs/design/questions/screens/the-page-is-two-panes-ascii-110.png differ diff --git a/docs/design/questions/screens/the-page-is-two-panes-column-96.ansi b/docs/design/questions/screens/the-page-is-two-panes-column-96.ansi new file mode 100644 index 0000000000..35c5e716b9 --- /dev/null +++ b/docs/design/questions/screens/the-page-is-two-panes-column-96.ansi @@ -0,0 +1,29 @@ + aforge fri 5:51pm +  Home  new conversation ×  +  +──────────────────────────────────────────────────────────────────────────────────────────────── + +  which store should the ledger sit on? aforge asks · the turn waits on it + a schema change is next and it is cheaper before there are rows + +──────────────────────────────────────────────────────────────────────────────────────────────── +  ▸ 1 postgres  recommended + Rows already carry a foreign key into it and the migration is one file. + + one place to back up + - another service to run locally + + then · the ledger and the rest of the project share one connection + why this one · because it is the only store the reporting job already reads + would switch if the ledger ever has to run on a machine with no server on it + confidence · fairly sure + + what it would look like +  app --> pg --> report + + 2 sqlite beside the project the ledger travels with the checkout + 3 a file per day  the ledger is readable with cat and nothing else + 4 something else…  + +─ answering 1 postgres · just this once ──────────────────────────────────────────────────────── + ↑↓ choose · enter take it · esc later ? ask back · d you decide · t how long + ›  + idle diff --git a/docs/design/questions/screens/the-page-is-two-panes-column-96.png b/docs/design/questions/screens/the-page-is-two-panes-column-96.png new file mode 100644 index 0000000000..d97879d98d Binary files /dev/null and b/docs/design/questions/screens/the-page-is-two-panes-column-96.png differ diff --git a/docs/design/questions/screens/the-page-is-two-panes-detail-140.ansi b/docs/design/questions/screens/the-page-is-two-panes-detail-140.ansi new file mode 100644 index 0000000000..dc2c5556d7 --- /dev/null +++ b/docs/design/questions/screens/the-page-is-two-panes-detail-140.ansi @@ -0,0 +1,40 @@ + aforge fri 5:54pm +  Home  new conversation ×  +  +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + +  which store should the ledger sit on? aforge asks · the turn waits on it + a schema change is next and it is cheaper before there are rows + +───────────────────────────────────────────────────┬──────────────────────────────────────────────────────────────────────────────────────── +  ▸ 1 postgres   recommended │ postgres + 2 sqlite beside the project │ Rows already carry a foreign key into it and the migration is one file. + 3 a file per day │ + one place to back up + 4 something else… │ - another service to run locally + │ + │ then · the ledger and the rest of the project share one connection + │ why this one · because it is the only store the reporting job already reads + │ would switch if the ledger ever has to run on a machine with no server on it + │ confidence · fairly sure + │ + │ what it would look like + │  app --> pg --> report + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ +─ answering 1 postgres · just this once ───────────┴──────────────────────────────────────────────────────────────────────────────────────── + enter take it · esc later · ↑↓ scroll · ← back to the answers c change · x compare · ? ask back · d you decide · t how long + ›  + idle diff --git a/docs/design/questions/screens/the-page-is-two-panes-detail-140.png b/docs/design/questions/screens/the-page-is-two-panes-detail-140.png new file mode 100644 index 0000000000..e5f2a1cf49 Binary files /dev/null and b/docs/design/questions/screens/the-page-is-two-panes-detail-140.png differ diff --git a/docs/design/questions/screens/the-page-is-two-panes-layout-140.ansi b/docs/design/questions/screens/the-page-is-two-panes-layout-140.ansi new file mode 100644 index 0000000000..6b6c37fec6 --- /dev/null +++ b/docs/design/questions/screens/the-page-is-two-panes-layout-140.ansi @@ -0,0 +1,40 @@ + aforge fri 5:51pm +  Home  new conversation ×  +  +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + +  which of these two layouts for the status line? aforge asks · nothing is waiting on it + both fit at eighty columns and they cut different things first + +───────────────────────────────────────────────┬──────────────────────────────────────────────────────────────────────────────────────────── +  ▸ 1 money on the right   recommended │ money on the right + 2 money under the model │ + 3 something else…  │ why this one · the model name is what people look for + │ confidence · sure + │ + │ wide, then narrow + │ deepseek-v4-flash │ deepseek-v4-fl... + │  84k of 128k │ 84k + │  $0.14 │ $0.14 + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ +─ answering 1 money on the right ──────────────┴──────────────────────────────────────────────────────────────────────────────────────────── + ↑↓ choose · enter take it · esc later · → detail c change · ? ask back · d you decide · D decide these from now on + ›  + idle diff --git a/docs/design/questions/screens/the-page-is-two-panes-layout-140.png b/docs/design/questions/screens/the-page-is-two-panes-layout-140.png new file mode 100644 index 0000000000..95edf7e637 Binary files /dev/null and b/docs/design/questions/screens/the-page-is-two-panes-layout-140.png differ diff --git a/docs/design/questions/screens/the-page-is-two-panes-phone-56.ansi b/docs/design/questions/screens/the-page-is-two-panes-phone-56.ansi new file mode 100644 index 0000000000..026512344c --- /dev/null +++ b/docs/design/questions/screens/the-page-is-two-panes-phone-56.ansi @@ -0,0 +1,36 @@ + aforge fri 5:51pm +  Home  new conversation ×  +  +──────────────────────────────────────────────────────── + +  which store should the ledger sit on? + a schema change is next and it is cheaper before + there are rows + aforge asks · the turn waits on it + +──────────────────────────────────────────────────────── +  ▸ 1 postgres  recommended + Rows already carry a foreign key into it and the + migration is one file. + + one place to back up + - another service to run locally + + then · the ledger and the rest of the project + share one connection + why this one · because it is the only store the + reporting job already reads + would switch if the ledger ever has to run on a + machine with no server on it + confidence · fairly sure + + what it would look like +  app --> pg --> report + + 2 sqlite beside the project the ledger travels … + 3 a file per day  the ledger is reada… + 4 something else…  + +─ answering 1 postgres · just this once ──────────────── + ↑↓ choose · enter take it · esc later + ›  + af-qv-R ▸ + openai/gpt-4o-mini idle diff --git a/docs/design/questions/screens/the-page-is-two-panes-phone-56.png b/docs/design/questions/screens/the-page-is-two-panes-phone-56.png new file mode 100644 index 0000000000..06aea35c5f Binary files /dev/null and b/docs/design/questions/screens/the-page-is-two-panes-phone-56.png differ diff --git a/internal/e2e/questions_e2e_test.go b/internal/e2e/questions_e2e_test.go index a4f6dbab11..94ad307eed 100644 --- a/internal/e2e/questions_e2e_test.go +++ b/internal/e2e/questions_e2e_test.go @@ -116,6 +116,11 @@ func TestQuestionsE2E(t *testing.T) { const ( questionCols = 100 questionRows = 36 + // questionPageCols is the one scenario that is drawn wider: the page's two + // panes stand where the BODY is a hundred cells or more, and the task + // column — two cells even stowed — is charged against the body, so a + // hundred-column terminal can only ever draw the page as one column. + questionPageCols = 120 ) // questionRig is one aforge on this machine's own keyboard, IN THIS PROCESS. @@ -132,9 +137,13 @@ func questionRig(t *testing.T, name string, overrides map[string]any) *rig { // questionRigAt is the same terminal on a state root and a project the caller // already made — what the reach scenario needs, because two windows answering // one question have to be two windows on ONE machine. -func questionRigAt(t *testing.T, name, home, ws string) *rig { +func questionRigAt(t *testing.T, name, home, ws string, cols ...int) *rig { t.Helper() - return start(t, name, home, ws, questionCols, questionRows, "chat", "--no-host") + wide := questionCols + if len(cols) > 0 && cols[0] > 0 { + wide = cols[0] + } + return start(t, name, home, ws, wide, questionRows, "chat", "--no-host") } // steer types a message and sends it. @@ -462,8 +471,8 @@ func questionsBlocksUnderAnswers(t *testing.T) { } press(t, r, "o") - room := r.waitFor(20*time.Second, say(t, "questionRoomBackWord"), say(t, "questionRoomPickWord")) - screenSays(t, room, "HRV", "the room lists the answers") + room := r.waitFor(20*time.Second, say(t, "questionRoomWaitsWord")) + screenSays(t, room, "HRV", "the page lists the answers") shot(t, r, "open") press(t, r, "Enter") @@ -505,7 +514,17 @@ var questionCountdown = regexp.MustCompile(`start it in \d+s`) // reply, because an answer that does not reach the asker is a page with no door // out of it. func questionsRoom(t *testing.T) { - r := questionRig(t, "q-room", nil) + // THIS ONE SCENARIO GETS A WIDER TERMINAL AND A STOWED TASK COLUMN, because + // it is the one that drives the page's two panes. The page is drawn in the + // BODY and the column is charged against the body, so the split needs + // `questionPageCols` less the stowed column's two cells to clear the + // two-column floor. Both are pinned here rather than left to the machine's + // own profile: a scenario whose layout depends on a setting somebody's + // laptop happens to carry is a scenario that passes in one place and hangs + // in another. + r := questionRigAt(t, "q-room", + newHome(t, map[string]any{config.KeyTaskColumn: false}), + newWorkspace(t, "q-room", false), questionPageCols) steer(t, r, `Call the ask tool now, exactly once, and run nothing else. `+ `head "which store should the ledger sit on?", kind choice, form room, `+ `reason "a schema change is next and it is cheaper before rows exist", `+ @@ -522,14 +541,31 @@ func questionsRoom(t *testing.T) { shot(t, r, "card") press(t, r, "o") - room := r.waitFor(20*time.Second, say(t, "questionRoomBackWord"), say(t, "questionRoomPickWord")) - screenSays(t, room, "which store should the ledger sit on?", "the room's head") + room := r.waitFor(20*time.Second, say(t, "questionRoomWaitsWord")) + screenSays(t, room, "which store should the ledger sit on?", "the page's head") screenSays(t, room, say(t, "questionRoomWaitsWord"), "the room says what is stopped on it") screenSays(t, room, say(t, "questionRoomNoPickWord"), "the foot with nothing chosen yet") screenSays(t, room, "what it would look like", "the attached block") screenSays(t, room, say(t, "questionWouldSwitchWord"), "what would change the asker's mind") shot(t, r, "open") + // TWO PANES, AND THE ARROWS MOVING BETWEEN THEM (owner ruling 2026-09-11, + // page pick A): the answers on the left, the evidence of the one the pointer + // is on beside them, and `→ detail` on the foot because there IS a pane to + // move into. A page of one column unfolds that evidence under the pointer + // and does not offer the key. + screenSays(t, room, say(t, "questionPageDetailWord"), "the page is two panes at this width") + shot(t, r, "split") + + press(t, r, "Right") + reading := r.waitFor(15*time.Second, say(t, "questionPageScrollWord")) + screenSays(t, reading, say(t, "questionWouldSwitchWord"), + "the arrows are the evidence pane's, and the evidence is still what they are on") + press(t, r, "Left") + back := r.waitFor(15*time.Second, say(t, "questionPageDetailWord")) + screenSilent(t, back, say(t, "questionPageScrollWord"), + "the arrows are the list's again, so the key that gives them back is not offered") + press(t, r, "x") compare := r.waitFor(15*time.Second, say(t, "questionCompareOnlyWord")) screenSays(t, compare, "ops cost", "the compare table is built on the asker's own dimensions") @@ -613,7 +649,7 @@ func questionsBlanks(t *testing.T) { shot(t, r, "card") press(t, r, "o") - room := r.waitFor(20*time.Second, say(t, "questionRoomBackWord")) + room := r.waitFor(20*time.Second, say(t, "questionRoomWaitsWord")) // THE HOLES WEAR THEIR OWN NAMES. A form of unlabelled boxes is a form // nobody can fill: the asker named each field and the page owes those names. screenSays(t, room, "table", "the first hole's name") @@ -650,7 +686,7 @@ func questionsChecklist(t *testing.T) { awaitQuestion(t, r, "which checks should run before merge?", say(t, "questionOpenKeyWord")) press(t, r, "o") - room := r.waitFor(20*time.Second, say(t, "questionRoomBackWord"), "unit tests") + room := r.waitFor(20*time.Second, say(t, "questionRoomWaitsWord"), "unit tests") screenSays(t, room, say(t, "questionTickKeyWord"), "the checklist's own key is on the row: a shape whose verb is dropped for width is a shape nobody can work") shot(t, r, "open") @@ -686,7 +722,7 @@ func questionsPairs(t *testing.T) { awaitQuestion(t, r, "settle the two naming calls", say(t, "questionOpenKeyWord")) press(t, r, "o") - room := r.waitFor(20*time.Second, say(t, "questionRoomBackWord")) + room := r.waitFor(20*time.Second, say(t, "questionRoomWaitsWord")) screenSays(t, room, "the table", "the first pair's own name") screenSays(t, room, "ledger", "the first side") screenSays(t, room, "entries", "the second side") @@ -712,7 +748,7 @@ func questionsDial(t *testing.T) { awaitQuestion(t, r, "how hard should the retry loop try?", say(t, "questionOpenKeyWord")) press(t, r, "o") - room := r.waitFor(20*time.Second, say(t, "questionRoomBackWord")) + room := r.waitFor(20*time.Second, say(t, "questionRoomWaitsWord")) screenSays(t, room, say(t, "questionMoveItWord"), "the arrows move the dial rather than a cursor") shot(t, r, "open") diff --git a/internal/e2e/tuiwords_test.go b/internal/e2e/tuiwords_test.go index 680076e803..9ca8980d33 100644 --- a/internal/e2e/tuiwords_test.go +++ b/internal/e2e/tuiwords_test.go @@ -759,10 +759,12 @@ var tuiWords = map[string]tuiWord{ why: "`enter` is offered ONLY where the asker named a pick — the emptiness law on a key", }, "questionOpenKeyWord": { - screen: "[o] open it", - source: "open it", - why: "the key to the room, drawn only where opening would show more than the block already " + - "does; a page that says what the row said is a page nobody should be sent to", + screen: "o open full", + source: "open full", + why: "the key to the page, drawn only where opening would show more than the block already " + + "does; a page that says what the row said is a page nobody should be sent to. It was " + + "`[o] open it` here for a wave after the surface stopped bracketing its keys, which is " + + "exactly the rot this gate exists for", }, "questionChangeKeyWord": { screen: "[c] change", @@ -855,17 +857,19 @@ var tuiWords = map[string]tuiWord{ why: "the ratify line wears the SETTLED mark and not the attention one: nothing waits on it, so " + "a `?` there would be the surface asking for something it has already had", }, - "questionRoomBackWord": { - screen: "‹ back", - source: "‹", - pkg: tokensPkg, - why: "the room is a VIEW and not a modal, so it owes a crumb out. The mark is the vocabulary's " + - "scope-up and the word beside it is internal/tui3's", - }, - "questionRoomPickWord": { - screen: "my pick", - why: "the asker saying which one it would take. It is a fact about the question and never a " + - "cursor, which is a fact about the person", + "questionPageDetailWord": { + screen: "\u2192 detail", + source: "detail", + why: "the page's own key, and the one word that says it is the page and not the panel: two " + + "panes, and `\u2192` hands the arrows to the evidence beside the list. The page used to " + + "owe a `\u2039 back` crumb and to name the asker's answer `my pick`; it draws neither now " + + "(the recommendation wears `\u25c6 recommended` on its own row, in every view)", + }, + "questionPageScrollWord": { + screen: "back to the answers", + why: "and the other half of that: while the arrows are the evidence pane's, `\u2190` gives them " + + "back to the list. A page that took the arrows and never said how to get them back is a " + + "page a person is stuck in", }, "questionRoomWaitsWord": { screen: "the turn waits on it", diff --git a/internal/manual/chat/questions.md b/internal/manual/chat/questions.md index 96e2434b5f..187157e0e4 100644 --- a/internal/manual/chat/questions.md +++ b/internal/manual/chat/questions.md @@ -449,7 +449,7 @@ otherwise: The refusal always says what to do instead: choose, or say what is being assumed. -## How a question looks +## How a question looks — show me the options side by side The block sits directly above the box you type in, and it draws **one question at a time** with a count of anything behind it (`2 more`). It never covers the @@ -488,6 +488,34 @@ the note the asker wrote under it, and where it is the recommendation, why it would take it, how sure it is, and what would change its mind. Move the pointer and the lines move with it. `o` opens the page that has all of it. +**Where the answers brought things to look at** — a diagram, a diff, a table, two +layouts — the panel splits down the middle and shows them side by side: the +answers on the left, and on the right the word of the answer you are standing on, +what it means, its dim `then ·` / `why this one ·` / `would switch if` / +`confidence ·` lines, and everything it drew. Walk the pointer and the right-hand +side changes with it. + +``` +╭─ ? which store should the ledger sit on? ─────────────── aforge asks · waiting ─╮ +│ a schema change is next and it is cheaper before there are rows │ +│ │ │ +│ ▸ 1 postgres ◆ recommended │ postgres │ +│ 2 sqlite beside the project │ Rows already carry a foreign key │ +│ 3 a file per day │ into it. │ +│ 4 something else… │ then · one connection for both │ +│ │ confidence · fairly sure │ +╰─ ↑↓ choose · enter take it · esc later ─────────────────────────────────────────╯ + o open full · c change · ? ask back · d you decide · 1–3 jump +``` + +**Narrower than about a hundred columns there is no room for two**, so the same +lines unfold under the answer's own row instead — and the row gives its `then` +line up to them rather than saying it twice and cutting it the first time. + +Either way **the panel never takes more than half the screen**; the conversation +keeps the rest. Evidence longer than that is cut on a dim line that says what is +left and the way to it: `… 4 more lines · o open full`. + **The last answer is `something else…`.** Walk the pointer onto it and the row becomes a box you type your own answer into: `enter` sends what you wrote as the answer, `↑` goes back to the list, `esc` puts the question off. `c` is a shortcut @@ -563,12 +591,12 @@ the row. | key | what it does | | --- | --- | -| `1`–`9` | take that answer — these work straight away | +| `1`–`9` | take that answer — these work straight away, except on the page a question opens into, where a digit walks the pointer to that answer and `enter` takes it | | `enter` | take the answer the pointer is on — it starts on the recommended one, and on a permission it starts on the answer that loses nothing, so `enter` on a permission you have not moved the pointer on **denies** | -| `↑` `↓` `←` `→` | move the pointer (`↑↓` on the panel, `←→` on a one-row question; both pairs work on both). On the **page** a question opens into, `↑↓` walk the answers and `←→` fold and open the one you are on | +| `↑` `↓` `←` `→` | move the pointer (`↑↓` on the panel, `←→` on a one-row question; both pairs work on both). On the **page** a question opens into, `↑↓` walk the answers, `→` hands them to the evidence beside them and `←` takes them back | | `esc` | later. Nothing is cancelled — the question folds to one titled rule where it stood | | `space` | open a question that has been folded to its rule, while the box is empty | -| `o` | open full: the page with everything the asker attached. Inside the page it opens and folds the answer you are on | +| `o` | open full: the page with everything the asker attached. It is not a key on the page itself — the page IS what it opens | | `c` | change — take an answer, but say what you want different. It moves the pointer to the `something else…` row carrying the answer you were on; on a question that asked for words it points the message box at the question instead | | `?` | ask back before answering. The row becomes `ask back: type your question, then enter · the question stays open`; the question is still there to answer. On a question the model itself asked, the reply comes back while you are still deciding; on a permission, a task proposal or a standing card it reaches the model after you answer | | `d` | you decide | @@ -812,7 +840,7 @@ again is refused in words, because what it allowed has already happened. Those are the ones that were never on a clock and that nobody but you was ever allowed to answer. -## Open a question up and read it properly — the page a question opens into +## Open a question up and read it properly — open it bigger, the page a question opens into A question that carries more than a card can hold — a body under each answer, a diagram, a diff, a table of what each one costs — opens into a page of its own, @@ -823,15 +851,28 @@ taking words, and closing the page puts the conversation back exactly where it was, scrolled where you left it. **Closing it is not answering it**: `esc` means later, the question folds away, and whatever was waiting on it is still waiting. +**Wide enough, the page is two panes with a rule across the top of them.** The +answers stand on the left; the answer you are standing on has its evidence on the +right, and the right-hand side follows the pointer. Narrower than about a hundred +columns the page is one column and that answer unfolds under its own row instead +— the same lines, stacked rather than beside. (The task column counts against the +width: `ctrl+g` stows it and gives the page its cells back.) + The page has, from the top: - the question in one sentence, with the amber `?` -- who is asking and why now, dim -- what is waiting on it and what carries on without it +- who is asking and what is waiting on it, dim — `aforge asks · the turn waits on + it` — with the reason under it +- the answers, one row each, `something else…` last, with `◆ recommended` on the + one the asker would take - anything it drew for the whole decision, under one dim heading `what it showed you` -- one section per answer, folded shut except the one it would take -- a foot pinned above the box saying what `enter` would send +- the answer you are standing on, in full — beside the list, or under its row +- a foot pinned above the box: what `enter` would send, then the keys in two + tiers, the ones that answer on the left and the quieter ones on the right + +There are no folding sections on the page and no `‹ back` row: one answer is open +at a time and it is the one the pointer is on. **A key pressed in the first quarter second is dropped.** A page that appears under a hand already moving would otherwise turn your next keystroke into an @@ -843,41 +884,57 @@ being text. ## Moving around the page — the arrows, and clicking an answer -`↑` and `↓` walk down the answers. The one you are on wears a band, and the foot -says `↑↓ choose`. +`↑` and `↓` walk down the answers. The one you are on wears a band, the foot says +`↑↓ choose`, and **the answer you are on stays on screen** — the list scrolls to +it rather than leaving you standing on a row that has gone past the edge. + +`→ detail` hands the arrows to the evidence: `↑↓ scroll` moves it, and `← back to +the answers` gives them back to the list. The foot says which of the two the +arrows belong to. -`→` opens the answer you are on and `←` folds it again. They say what they want -rather than toggling, so holding one down is safe. +`enter` **takes** the answer you are on — that is the answer, sent. The page opens +standing on the one it would take, so `enter` straight away still takes its +recommendation. -`enter` **takes** the answer you are on — that is the answer, sent. The page -opens standing on the one it would take, so `enter` straight away still takes -its recommendation. A digit `1`–`9` answers at once from anywhere, whichever -answer you are standing on. +**A digit moves the pointer here; it does not answer.** `1`–`9` walks to that +answer and brings its evidence up, and `enter` takes it. On the block above the +box a digit still answers at once — the page is what you opened in order to read +before deciding, so nothing on it decides on one keystroke. (On a checklist a +digit ticks that answer and ticks it off again.) **Clicking works too, and it takes two clicks to answer.** The first click on an -answer's row moves onto it and opens it; a second click on that same row is -`enter`. One press to read, one to decide — so a click on a page you have not -finished reading cannot decide anything. Clicking a body, a diagram or a note of -your own does nothing: only an answer's own row answers to the mouse, and it is -the only row that lights up under the pointer. +answer's row moves onto it and brings up its evidence; a second click on that +same row is `enter`. One press to read, one to decide — so a click on a page you +have not finished reading cannot decide anything. Clicking the evidence beside +the list, a diagram, or a note of your own does nothing: only an answer's own row +answers to the mouse, and it is the only row that lights up under the pointer. -## What each line under an answer means +The wheel scrolls whichever side it is over: the answers on the left, the +evidence on the right. -An open answer reads in three tiers, and every line says which it is: +## What each line under an answer means — what does each option look like -- the **label** — the digit and the word, bold and amber, the heading of that - section +The answer you are standing on is drawn in full — beside the list where there is +room for two panes, under its own row where there is not — and it reads in three +tiers, every line saying which it is: + +- the **label** — the answer's own word, bold, at the head of the pane. Under its + own row there is no second label: the row above it is the label - what it **means** — the asker's own paragraph, in ordinary ink - the asides, dim, each with its own word in front: - `then ·` what taking it would leave true - `why this one ·` why the asker would take it, on the one it recommends - `would switch if` what would change the asker's mind, which is usually exactly what you disagree with if you disagree -- anything it drew for that answer — a diagram, a diff, a table — under a dim - title of its own, a blank row above it + - `confidence ·` how sure the asker is — `sure`, `fairly sure`, `a guess` +- anything it drew for that answer — a diagram, a diff, a table, two layouts side + by side — a blank row above each + +It is the same account of an answer everywhere it is drawn: beside the list, under +its row, and in the panel above your box. -A blank row closes each open answer, so the next answer's label is not just a -different indent. +The answers you are not on keep their row and nothing else. What one of them would +leave true is said once, in its own evidence, when you walk to it. ## Compare the options diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index 26c865f0d8..ead646066e 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -84,6 +84,12 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { // nine things a person does on it. Each one is asked in the words // somebody would actually type rather than in the key's own name. {"can I open a question up to read it properly", "questions"}, + // And lane R's three, in the asker's own words: the owner asked for + // exactly these sentences (2026-09-11) and each one is a person + // describing the drawing rather than naming a key. + {"show me the options side by side", "questions"}, + {"open it bigger", "questions"}, + {"what does each option look like", "questions"}, {"compare the options it gave me", "questions"}, {"how do I comment on one option", "questions"}, {"ask it something before I decide", "questions"}, diff --git a/internal/tui2/tokens/glyph.go b/internal/tui2/tokens/glyph.go index 234b2197ca..4a777eb38a 100644 --- a/internal/tui2/tokens/glyph.go +++ b/internal/tui2/tokens/glyph.go @@ -101,6 +101,12 @@ const ( GlyphFrameBottomRight = "╯" GlyphFrameEdge = "─" GlyphFrameSide = "│" + // The two junctions are where a frame's rule meets the seam between two + // panes laid side by side (internal/tui3/panes.go): the rule above the panes + // drops into the seam and the rule below closes it. They are the same + // geometry as the rest of the frame, so they are drawn at the same floor. + GlyphFrameTeeDown = "┬" + GlyphFrameTeeUp = "┴" // GlyphTarget is WHERE THE NEXT THING GOES, and it is the one mark in this // vocabulary about a destination rather than about a state. Home's rule wears @@ -430,6 +436,8 @@ func Glyphs() []GlyphInfo { {"FrameBottomRight", GlyphFrameBottomRight, '╯', true}, {"FrameEdge", GlyphFrameEdge, '─', true}, {"FrameSide", GlyphFrameSide, '│', true}, + {"FrameTeeDown", GlyphFrameTeeDown, '┬', true}, + {"FrameTeeUp", GlyphFrameTeeUp, '┴', true}, {"Target", GlyphTarget, '→', true}, {"Truncated", GlyphTruncated, '⋯', false}, {"Ellipsis", GlyphEllipsis, '…', true}, diff --git a/internal/tui2/tokens/glyph_test.go b/internal/tui2/tokens/glyph_test.go index 879080c3af..3384d8e04a 100644 --- a/internal/tui2/tokens/glyph_test.go +++ b/internal/tui2/tokens/glyph_test.go @@ -245,7 +245,7 @@ func TestGlyphInventoryIsComplete(t *testing.T) { GlyphQueued, GlyphWorking, GlyphSettled, GlyphFailed, GlyphPaused, GlyphNeedsHuman, GlyphWaitsOn, GlyphWithdrawn, GlyphCollapsed, GlyphExpanded, GlyphScopeUp, GlyphPointer, GlyphRecommended, GlyphFrameTopLeft, GlyphFrameTopRight, - GlyphFrameBottomLeft, GlyphFrameBottomRight, GlyphFrameEdge, GlyphFrameSide, GlyphTarget, GlyphTruncated, GlyphCut, GlyphPromptChat, GlyphPromptSteer, + GlyphFrameBottomLeft, GlyphFrameBottomRight, GlyphFrameEdge, GlyphFrameSide, GlyphFrameTeeDown, GlyphFrameTeeUp, GlyphTarget, GlyphTruncated, GlyphCut, GlyphPromptChat, GlyphPromptSteer, GlyphReplyIn, GlyphThought, GlyphShell, GlyphSearch, GlyphWrite, GlyphBoosted, GlyphSeparator, GlyphMissing, GlyphEstimate, diff --git a/internal/tui2/tokens/glyphset.go b/internal/tui2/tokens/glyphset.go index d8aad073d0..a352aae05f 100644 --- a/internal/tui2/tokens/glyphset.go +++ b/internal/tui2/tokens/glyphset.go @@ -122,6 +122,8 @@ const ( GFrameBottomRight GFrameEdge GFrameSide + GFrameTeeDown + GFrameTeeUp GTarget GTruncated GEllipsis diff --git a/internal/tui2/tokens/nerdfont.go b/internal/tui2/tokens/nerdfont.go index 718266225d..12af9b2fad 100644 --- a/internal/tui2/tokens/nerdfont.go +++ b/internal/tui2/tokens/nerdfont.go @@ -222,6 +222,14 @@ var vocabulary = []GlyphBinding{ ID: GFrameSide, Name: "FrameSide", Meaning: "the frame: its sides", Plain: GlyphFrameSide, UsualTint: TextTertiary, PlainAmbiguous: true, Geometry: true, }, + { + ID: GFrameTeeDown, Name: "FrameTeeDown", Meaning: "the frame: where the rule above two panes meets the seam between them", + Plain: GlyphFrameTeeDown, UsualTint: TextTertiary, PlainAmbiguous: true, Geometry: true, + }, + { + ID: GFrameTeeUp, Name: "FrameTeeUp", Meaning: "the frame: where the rule below two panes closes the seam between them", + Plain: GlyphFrameTeeUp, UsualTint: TextTertiary, PlainAmbiguous: true, Geometry: true, + }, { ID: GTarget, Name: "Target", Meaning: "where the next thing goes — the destination of a draft", // Tinted with the identity of the room the draft will land in, exactly as diff --git a/internal/tui3/app.go b/internal/tui3/app.go index dd184fff5e..b46d82b315 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -3606,9 +3606,9 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { if a.questionRoomOpen() { switch msg.Mouse().Button { case tea.MouseWheelUp: - a.questionRoomScroll(-3) + a.questionRoomScroll(msg.Mouse().X, -3) case tea.MouseWheelDown: - a.questionRoomScroll(3) + a.questionRoomScroll(msg.Mouse().X, 3) } return a, nil } @@ -6366,7 +6366,7 @@ func (a *app) press(x, y int) (cmd tea.Cmd) { // untouched and then does nothing, which is what the empty parts of any page // on this surface do. if a.questionRoomOpen() { - if cmd, took := a.questionRoomPress(y); took { + if cmd, took := a.questionRoomPress(x, y); took { return cmd } } diff --git a/internal/tui3/hover.go b/internal/tui3/hover.go index d0d7824282..1ad1f89f3c 100644 --- a/internal/tui3/hover.go +++ b/internal/tui3/hover.go @@ -516,7 +516,7 @@ func (a *app) hoverTarget(x, y int) hoverAt { // pointer answered from the transcript underneath would brighten a tool call // in a conversation the person cannot see (questionroom.go). if a.questionRoomOpen() { - if at, ok := a.questionRoomOptionAt(y); ok { + if at, ok := a.questionRoomSpotAt(x, y); ok { return hoverAt{kind: hoverQuestionOption, index: at} } } diff --git a/internal/tui3/panes.go b/internal/tui3/panes.go new file mode 100644 index 0000000000..386577886a --- /dev/null +++ b/internal/tui3/panes.go @@ -0,0 +1,126 @@ +package tui3 + +import ( + "strings" + + "github.com/charmbracelet/x/ansi" + + "github.com/Agent-Field/aforge-v2/internal/tui2/tokens" +) + +// ── TWO PANES, SIDE BY SIDE ───────────────────────────────────────────────── +// +// ONE WIDTH DECIDES WHETHER A FRAME HOLDS TWO COLUMNS, AND THIS SURFACE ALREADY +// HAD IT. The task column stands beside the conversation from [railSlimFloor] +// up and under it "the transcript is the thing a person came for" (task.go); +// the owner's picks of 2026-09-11 put a question's answers beside the evidence +// of the one the pointer is on at the same width (preview A, page A, "≥100 +// cols") and fold that evidence under its row below it (preview-narrow B). So +// there is one reading of it, [besideFits], and one way to lay two columns out, +// [besides] — the panel and the page both ask here, and neither measures its +// own. (The asker's own two-pane layout block decides by its CONTENT instead: +// its panes stand side by side wherever both fit whole, which is the reason a +// fixed width was only ever standing in for — questionblocks.go.) +// +// A SEAM, NOT A BOX. The two panes are one object split in two, so the line +// between them is the frame's own side ([tokens.GFrameSide]) and the rules +// above and below meet it with the frame's junctions ([tokens.GFrameTeeDown], +// [tokens.GFrameTeeUp]). A terminal refused box drawing gets the frame's ASCII +// run — a rule of `-` and a blank seam — for the frame's own reason: a column +// of `|` down every row is punctuation a screen reader reads aloud on every +// line, and a blank column already says where one pane ends. + +// besideFits reports whether a frame of this width has room for two panes +// side by side. +func besideFits(width int) bool { return width >= railSlimFloor } + +// besideSplit is where the seam falls in a frame of `width` cells whose left +// pane would like `want` of them. +// +// THE LEFT PANE GETS WHAT ITS WIDEST ROW NEEDS AND NEVER MORE THAN HALF. The +// right pane is what the split exists to show — the evidence — so it is always +// given at least as much as the list beside it; and the list is given exactly +// what its widest row needs, so no answer's word is cut while the evidence has +// room to give. The seam itself is one cell. +func besideSplit(width, want int) (left, right int) { + half := (width - 1) / 2 + left = min(max(want, 1), half) + return left, max(width-left-1, 0) +} + +// besides lays two columns of painted rows side by side at one width: the left +// set to `left` cells, the seam, the right set to the rest. The shorter column +// is padded with blank rows so the seam runs the height of the taller. +func besides(p palette, lefts, rights []string, left, width int) []string { + right := max(width-left-1, 0) + seam := p.dim(framePiecesOf(p).side) + tall := max(len(lefts), len(rights)) + out := make([]string, 0, tall) + for i := range tall { + l, r := "", "" + if i < len(lefts) { + l = lefts[i] + } + if i < len(rights) { + r = rights[i] + } + out = append(out, frameSet(l, left)+seam+frameSet(r, right)) + } + return out +} + +// besideRule is a full-width rule that meets a seam at column `at` with the +// junction `tee`, and carries words written into it the way the frame writes +// words into an edge ([framed.edge]). `at` below zero is a rule with no seam +// to meet. +// +// THE WORDS START TWO CELLS IN, and where the seam falls inside them they move +// past it, so a junction never lands in the middle of a word and a rule over a +// narrow left pane still says what it carries. +func besideRule(p palette, width, at int, tee tokens.GlyphID, words string) string { + if width <= 0 { + return "" + } + pieces := framePiecesOf(p) + junction := pieces.edge + if at >= 0 && !p.ascii { + junction = p.glyph(tee) + } + edge := func(n int) string { return strings.Repeat(pieces.edge, max(n, 0)) } + wordsW := ansi.StringWidth(words) + if words == "" { + if at < 0 || at >= width { + return p.dim(edge(width)) + } + return p.dim(edge(at) + junction + edge(width-at-1)) + } + // Where the words would cross the seam they start one rule cell past it. + start := 2 + if at >= 0 && at < width && start+wordsW+1 > at { + start = at + 2 + } + room := max(width-start-2, 0) + if wordsW > room { + words, wordsW = fitWidth(words, room) + } + if wordsW == 0 { + return besideRule(p, width, at, tee, "") + } + var b strings.Builder + lead := start - 1 + if at >= 0 && at < lead { + b.WriteString(p.dim(edge(at) + junction + edge(lead-at-1))) + } else { + b.WriteString(p.dim(edge(lead))) + } + b.WriteString(" ") + b.WriteString(words) + b.WriteString(" ") + used := lead + wordsW + 2 + if at >= used && at < width { + b.WriteString(p.dim(edge(at-used) + junction + edge(width-at-1))) + } else { + b.WriteString(p.dim(edge(width - used))) + } + return b.String() +} diff --git a/internal/tui3/question.go b/internal/tui3/question.go index 29191d2a4d..f8307bb2f3 100644 --- a/internal/tui3/question.go +++ b/internal/tui3/question.go @@ -1009,7 +1009,13 @@ func (a *app) questionRows(width int) []string { // A QUESTION PUT OFF FOLDS IN PLACE AND NOT TO THE FAR SIDE OF THE // FRAME (owner ruling 2026-09-11, fold pick A): one titled rule where // the panel was, saying what is waiting and how to open it again. - if folded, has := a.questionPutOff(); has && a.questionQuieted() { + // + // AND NOT UNDER THE PAGE THAT IS SHOWING IT. `o` folds the question it + // opens, so the block will not draw it a second time with keys of its + // own ([app.openQuestionRoom]). Its fold rule under the page would still be + // 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 diff --git a/internal/tui3/questionblocks.go b/internal/tui3/questionblocks.go index d91362d976..9a15c80423 100644 --- a/internal/tui3/questionblocks.go +++ b/internal/tui3/questionblocks.go @@ -32,8 +32,9 @@ import ( // - image — the surface's existing picture path ([app.pictureRowsFor]), which // is the same renderer, cache and half-cell painting a picture in the // conversation gets. A terminal that cannot paint gets the path and `o open`. -// - layout — two pre-formatted panes side by side above 100 columns, stacked -// below, which is DESIGN.md's own number. +// - layout — two pre-formatted panes side by side wherever both fit whole, +// stacked where they do not, because a pane cut to fit is a pane that has +// stopped being pre-formatted. // // ── NO BOX DRAWING ── // @@ -78,7 +79,7 @@ func (a *app) questionBlockRows(block session.Block, indent, width int) []string case session.BlockImage: body = a.questionImageLines(block, inner) case session.BlockLayout: - body = a.questionLayoutLines(block, inner, width) + body = a.questionLayoutLines(block, inner) default: for _, line := range wrap(strings.TrimSpace(block.Body), inner) { body = append(body, a.pal.ink(line)) @@ -216,35 +217,30 @@ func (a *app) questionImageLines(block session.Block, width int) []string { return out } -// questionPane fits one pre-formatted line to a column, padding on the right and -// trimming nothing: the spaces inside a drawing are the drawing. -func questionPane(text string, width int) string { - if width <= 1 { - return "" - } - text = fit(text, width-1) - if pad := width - ansi.StringWidth(text); pad > 0 { - return text + strings.Repeat(" ", pad) - } - return text -} - // questionLayoutLines draws two pre-formatted panes beside each other, or under -// each other on a page too narrow to hold both. +// each other where the two will not both fit whole. // // THE PANES ARE [session.Block.Rows], ONE ENTRY PER PANE. It is the field the // object already has for structured content, and a layout IS two lists of lines; // carving them out of one Body with a separator would mean inventing a separator // that a pane could contain. -func (a *app) questionLayoutLines(block session.Block, width, full int) []string { +// +// THE WIDTH IS THE CONTENT'S, NOT A NUMBER. A layout block used to go side by +// side above a hundred columns of PAGE; drawn in the pane beside a list, the same +// block has seventy, and two panes of seventeen-character lines stacked there for +// no reason a person could see. What the number stood in for is "a pane cut to +// thirty characters has stopped being pre-formatted", so that is the test: each +// pane's widest line fits its half, or the two stack. +func (a *app) questionLayoutLines(block session.Block, width int) []string { panes := block.Rows if len(panes) == 0 { return a.questionDiagramLines(block, width) } - if len(panes) == 1 || full < questionLayoutFloor { - // STACKED, WITH A BLANK BETWEEN THEM. DESIGN.md's own number: two panes - // side by side above 100 columns and stacked below, because a pane cut to - // thirty characters is a pane that has stopped being pre-formatted. + // The seam costs a cell and the pane past it a cell of air, so what the + // right-hand drawing has is two fewer than the rest of the row. + half := (width - 1) / 2 + if len(panes) == 1 || questionPaneWidest(panes[0]) > half || questionPaneWidest(panes[1]) > width-half-2 { + // STACKED, WITH A BLANK BETWEEN THEM. out := make([]string, 0, 16) for i, pane := range panes { if i > 0 { @@ -256,23 +252,27 @@ func (a *app) questionLayoutLines(block session.Block, width, full int) []string } return out } - left, right := panes[0], panes[1] - half := (width - 2) / 2 - tall := max(len(left), len(right)) - out := make([]string, 0, tall) - for i := range tall { - l, r := "", "" - if i < len(left) { - l = expandTabs(left[i]) - } - if i < len(right) { - r = expandTabs(right[i]) - } - // THE PANES ARE PRE-FORMATTED, SO THE LEFT ONE IS PADDED AND NEVER - // TRIMMED. [questionCell] trims — it is a table cell, where a stray space - // is noise — and running a pane through it flattened every indent inside - // the drawing the asker made. - out = append(out, a.pal.ink(questionPane(l, half+1))+a.pal.ink(fit(r, half))) + // THE PANES ARE PRE-FORMATTED, SO NOTHING IN THEM IS TRIMMED: the spaces + // inside a drawing are the drawing. They are laid by the one side-by-side + // ([besides]), whose seam is the frame's own side. + lefts, rights := make([]string, 0, len(panes[0])), make([]string, 0, len(panes[1])) + for _, line := range panes[0] { + lefts = append(lefts, a.pal.ink(expandTabs(line))) } - return out + for _, line := range panes[1] { + // ONE CELL OF AIR PAST THE SEAM. The left pane opens on the block's own + // left edge; a right pane written hard against the seam reads as a + // drawing with a wall through it. + rights = append(rights, a.pal.ink(" "+expandTabs(line))) + } + return besides(a.pal, lefts, rights, half, width) +} + +// questionPaneWidest is the widest line of one pane, measured as it is drawn. +func questionPaneWidest(pane []string) int { + widest := 0 + for _, line := range pane { + widest = max(widest, ansi.StringWidth(expandTabs(line))) + } + return widest } diff --git a/internal/tui3/questionchooser.go b/internal/tui3/questionchooser.go index 22445c0b62..57d646185a 100644 --- a/internal/tui3/questionchooser.go +++ b/internal/tui3/questionchooser.go @@ -42,6 +42,10 @@ const ( // viewPanel is the framed panel a question hangs in above the box // (questionpanel.go). It is the ordinary answer. viewPanel + // viewSplit is the same panel with its answers in a list on the left and + // the evidence of the one the pointer is on in a pane on the right, which + // follows the pointer (owner ruling 2026-09-11, preview pick A). + viewSplit // viewRow is one row: a question whose whole decision fits beside its own // answers, and the ratify line, which is not asking anything. viewRow @@ -92,9 +96,19 @@ var questionLadder = []questionRung{ return q.question.Input.Kind != session.InputNone }, }, + { + view: viewSplit, + why: "an answer brought something to look at and the frame is wide enough to lay it beside " + + "the list, so the evidence follows the pointer in a pane of its own rather than being " + + "folded under one row", + when: func(a *app, q questionShown, width int) bool { + return questionAnswersCarryBlocks(q.question) && besideFits(width) + }, + }, { view: viewPanel, - why: "an answer brought something to look at, and evidence sets the size of the drawing", + why: "an answer brought something to look at, and evidence sets the size of the drawing — " + + "narrower than two panes, the answer the pointer is on unfolds it under its own row", when: func(a *app, q questionShown, width int) bool { return questionCarriesBlocks(q.question) }, @@ -135,9 +149,15 @@ func (a *app) questionViewOf(q questionShown, width int) questionView { // questionCarriesBlocks reports whether any answer brought something to look // at, or the question itself did. func questionCarriesBlocks(q session.Question) bool { - if len(q.Attach) > 0 { - return true - } + return len(q.Attach) > 0 || questionAnswersCarryBlocks(q) +} + +// questionAnswersCarryBlocks reports whether any ANSWER brought something to +// look at — the evidence that is different from one answer to the next, which is +// the only evidence a pane following the pointer has anything to show about. +// The question's own [session.Question.Attach] is about the whole decision and +// is the page's to draw ("what it showed you"). +func questionAnswersCarryBlocks(q session.Question) bool { for _, option := range q.Options { if len(option.Blocks) > 0 { return true diff --git a/internal/tui3/questiondemo.go b/internal/tui3/questiondemo.go index 3b318a4f5e..d1ff668099 100644 --- a/internal/tui3/questiondemo.go +++ b/internal/tui3/questiondemo.go @@ -76,7 +76,8 @@ func (a *app) openDemoQuestion(env func(string) string) { if !known { return } - a.raiseQuestionRoom(questionShown{question: build(), shown: a.now()}) + q := build() + a.raiseQuestionRoom(questionShown{question: q, shown: a.now(), pick: questionPointerStart(q)}) // THE SETTLE GUARD IS SPENT BEFORE THE FIRST FRAME on a fixture, and only on // a fixture. It exists to protect a person from a page that appeared under a // hand already moving; a page raised by the launch itself appeared under @@ -258,11 +259,17 @@ func demoQuestionLayout() session.Question { // OWN BUILDERS wherever there is one, so a fixture cannot drift from the thing // it is a picture of. var blockDemos = map[string]func() session.Question{ - "standing": demoStandingCard, - "harness-offer": demoHarnessOffer, - "design": demoHarnessDesign, - "connect": demoConnectOffer, - "connect-key": demoConnectKey, + // THE EVIDENCE ON THE PANEL (lane R): the page's own worked example and its + // layout case, raised onto the block instead of the page, so the list with + // the evidence beside it — and, narrower, unfolded under the pointer — can be + // seen without a model. + "evidence": demoQuestionReading, + "evidence-layout": demoQuestionLayout, + "standing": demoStandingCard, + "harness-offer": demoHarnessOffer, + "design": demoHarnessDesign, + "connect": demoConnectOffer, + "connect-key": demoConnectKey, } // demoStandingCard is the reminder a turn proposed: a choice with four answers diff --git a/internal/tui3/questionevidence.go b/internal/tui3/questionevidence.go new file mode 100644 index 0000000000..cda86e9b9e --- /dev/null +++ b/internal/tui3/questionevidence.go @@ -0,0 +1,173 @@ +package tui3 + +import ( + "strings" + + "github.com/Agent-Field/aforge-v2/internal/session" + "github.com/Agent-Field/aforge-v2/internal/tui2/tokens" +) + +// ── ONE ANSWER'S CASE, AND THE EVIDENCE IT BROUGHT ────────────────────────── +// +// A QUESTION IS A DECISION HANDED TO THE PERSON WITH ITS EVIDENCE ATTACHED +// (owner ruling 2026-09-09), and the evidence is per answer: what it means, what +// taking it leaves true, why the asker would take it, what would change its +// mind, how sure it is, and whatever it drew — a diagram, a diff, a table, a +// layout, a picture. The owner's picks of 2026-09-11 draw that evidence in three +// places: +// +// - beside the list, in a pane that follows the pointer (preview A, a panel at +// a hundred columns and wider, and page A, the page `o` opens); +// - under the answer the pointer is on (preview-narrow B, the same panel +// narrower than that, and the page on a narrow frame); +// - folded to one line under the pointer, on a panel whose answers brought +// nothing to look at (rec A: `already a dependency · fairly sure`). +// +// ALL THREE READ ONE ACCOUNT OF THE CASE ([questionCase]) AND DRAW BLOCKS +// THROUGH ONE RENDERER ([app.questionBlockRows]). The page used to spell the +// labelled lines itself and the panel spelled its one-line case itself, so the +// two disagreed about whether `would switch if` joined a model's `If you…` twice +// — which the page had fixed and the panel had not. + +// questionCaseLine is one labelled line of an answer's case: the word that says +// which line it is, and what the asker said. +type questionCaseLine struct { + label string + text string + // pick is true for the lines only the asker's own pick carries. The one-line + // fold under a panel's pointer says only those, because the answer's row is + // already carrying what it leaves true beside its word. + pick bool +} + +// questionConfidenceLabel labels how sure the asker is, on the rows that say +// each line's word in front of it. +const questionConfidenceLabel = "confidence · " + +// questionCase is one answer's case, in the order a person weighs it: what +// taking it leaves true, why the asker would take it, what would change its +// mind, and how sure it is. +// +// THE EMPTINESS LAW. A line with nothing the asker said is no line at all — +// never an empty one, never the word "unknown" — and the last three exist only +// on the answer the asker picked, because a reason is a reason FOR something. +func questionCase(q session.Question, at int) []questionCaseLine { + if at < 0 || at >= len(q.Options) { + return nil + } + out := make([]questionCaseLine, 0, 4) + if then := strings.TrimSpace(q.Options[at].Consequence); then != "" { + out = append(out, questionCaseLine{label: questionThenWord, text: then}) + } + if q.Pick == nil || strings.TrimSpace(q.Pick.Key) != questionOptionKeyAt(q, at) { + return out + } + if why := strings.TrimSpace(q.Pick.Reason); why != "" { + out = append(out, questionCaseLine{label: questionWhyWord, text: why, pick: true}) + } + if change := strings.TrimSpace(q.Pick.WouldChange); change != "" { + out = append(out, questionCaseLine{label: questionWouldSwitchWord, text: questionAfterIf(change), pick: true}) + } + if sure := questionConfidenceWord(q.Pick.Confidence); sure != "" { + out = append(out, questionCaseLine{label: questionConfidenceLabel, text: sure, pick: true}) + } + return out +} + +// questionPickCase is the asker's case for its pick on ONE line — rec A's fold +// under a panel's pointer: `survives a crash mid-write · fairly sure · would +// switch if …`. It is [questionCase]'s pick lines joined, with the one label a +// bare clause cannot do without. +func questionPickCase(q session.Question, key string) string { + at, ok := questionOptionAt(q, key) + if !ok { + return "" + } + parts := make([]string, 0, 3) + for _, line := range questionCase(q, at) { + switch { + case !line.pick: + case line.label == questionWouldSwitchWord: + parts = append(parts, line.label+line.text) + default: + parts = append(parts, line.text) + } + } + return strings.Join(parts, " · ") +} + +// questionEvidenceRows draws one answer's evidence at `width` cells: its word +// (where `head` asks for it — a pane beside the list has to say whose evidence +// it is, a fold under the answer's own row does not), what it means in the +// reading ink, the labelled case dim, and every block it brought, a blank row +// above each. +// +// THE `something else…` ROW HAS NO EVIDENCE, and draws none: it is the person's +// answer, and there is nothing the asker said about it. +func (a *app) questionEvidenceRows(q session.Question, at, width int, head bool) []string { + if at < 0 || at >= len(q.Options) || width <= 0 { + return nil + } + option := q.Options[at] + out := make([]string, 0, 12) + if head { + word := strings.TrimSpace(option.Label) + if word == "" { + word = questionOptionKeyAt(q, at) + } + for _, line := range wrap(word, width) { + out = append(out, a.pal.bold(a.pal.ink(line))) + } + } + // AN ANSWER WITH NO BODY DRAWS NO ROW FOR ONE. [wrap] answers an empty + // string with one empty line, which is right for a paragraph and wrong here, + // where it would be a blank row claiming the asker said something. + if body := strings.TrimSpace(option.Body); body != "" { + for _, line := range wrap(body, width) { + out = append(out, a.pal.ink(line)) + } + } + // EVERY DIM LINE SAYS WHICH LINE IT IS. Four grey sentences stacked in one + // column read as one grey paragraph nobody can tell apart (the owner, + // 2026-09-10: "the same line and next line in options look same"), so what + // an answer MEANS is ink and what it costs and why the asker would take it + // are dim AND labelled. + case_ := questionCase(q, at) + if len(case_) > 0 && len(out) > 0 { + out = append(out, "") + } + for _, line := range case_ { + for _, row := range wrap(line.label+line.text, width) { + out = append(out, a.pal.dim(row)) + } + } + for _, block := range option.Blocks { + if len(out) > 0 { + out = append(out, "") + } + out = append(out, a.questionBlockRows(block, 0, width)...) + } + return out +} + +// questionCut keeps at most `room` rows of evidence, and says how much it left +// out on the last row it keeps. +// +// A CUT IS SAID, NEVER SILENT: evidence that stops mid-diagram with nothing +// under it reads as a diagram that ends there. The foot names the count and +// the way to the rest — the page `o` opens, where the same evidence has the +// height of the frame. +func (a *app) questionCut(rows []string, room int, offer string) []string { + if room <= 0 || len(rows) <= room { + if room <= 0 { + return nil + } + return rows + } + kept := append([]string{}, rows[:room-1]...) + more := a.icon(tokens.GEllipsis) + " " + itoa(len(rows)-room+1) + questionMoreWord + if offer != "" { + more += questionKeyGap + offer + } + return append(kept, a.pal.dim(more)) +} diff --git a/internal/tui3/questionevidence_test.go b/internal/tui3/questionevidence_test.go new file mode 100644 index 0000000000..68cc2c7f03 --- /dev/null +++ b/internal/tui3/questionevidence_test.go @@ -0,0 +1,410 @@ +package tui3 + +// THE EVIDENCE AN ANSWER BRINGS, WHEREVER A QUESTION IS DRAWN. +// +// These hold the owner's three picks of 2026-09-11 about evidence: at a hundred +// columns and wider a panel whose answers brought something to look at lays +// them in a list with the evidence of the one the pointer is on beside it +// (preview A); narrower, that evidence unfolds under the pointer's row +// (preview-narrow B); and `o` opens the page as two panes (page A). And they +// hold what keeps those drawings honest: the one chooser decides, the evidence +// follows the pointer, a panel never takes more than half the frame, a press on +// evidence presses nothing, the answer the pointer is on is always on screen, +// and the page's two rules meet one seam. + +import ( + "strings" + "testing" + "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" +) + +// tallEvidence is the worked example with a diagram under its pick far taller +// than any panel may be. +func tallEvidence() session.Question { + q := demoQuestionReading() + q.Form = session.FormCard + q.Options[0].Blocks = []session.Block{{Kind: session.BlockDiagram, Title: "all of it", + Body: strings.TrimRight(strings.Repeat("a row of the drawing\n", 30), "\n")}} + return q +} + +// THE SPLIT IS A RUNG OF THE ONE CHOOSER, AND IT READS WHAT THE ANSWERS CARRY +// AND THE WIDTH — nothing else. The same question takes the split from the width +// two columns stand at and the panel below it; a question whose only evidence is +// the question's own never splits, because a pane that follows the pointer would +// have nothing different to show for each answer. +func TestTheSplitIsARungOfTheOneChooser(t *testing.T) { + lab := newQuestionLab(t) + q := questionShown{question: demoQuestionReading(), shown: lab.a.now()} + if got := lab.a.questionViewOf(q, railSlimFloor); got != viewSplit { + t.Errorf("at %d columns answers with blocks take view %d, want the split", railSlimFloor, got) + } + if got := lab.a.questionViewOf(q, railSlimFloor-1); got != viewPanel { + t.Errorf("at %d columns answers with blocks take view %d, want the panel", railSlimFloor-1, got) + } + attached := demoQuestionReading() + for i := range attached.Options { + attached.Options[i].Blocks = nil + } + attached.Attach = []session.Block{{Kind: session.BlockText, Body: "the whole decision"}} + if got := lab.a.questionViewOf(questionShown{question: attached, shown: lab.a.now()}, 140); got != viewPanel { + t.Errorf("a question whose only evidence is its own takes view %d, want the panel", got) + } + found := false + for _, rung := range questionLadder { + found = found || rung.view == viewSplit + } + if !found { + t.Fatal("the split is not a rung of the ladder") + } +} + +// THE EVIDENCE FOLLOWS THE POINTER, ON THE PANEL AS ON THE PAGE. The list stays +// where it is and the pane beside it changes to the answer walked to — which is +// the whole of what a pane beside a list is for. +func TestTheEvidenceBesideTheListFollowsThePointer(t *testing.T) { + lab := newQuestionLab(t) + lab.a.width, lab.a.height = 140, 60 + lab.raise(demoQuestionReading()) + lab.tick(time.Second) + first := lab.plain() + if !strings.Contains(first, "Rows already carry a foreign key") || strings.Contains(first, "One file in the repository") { + t.Fatalf("the pane should hold the pick's evidence and only its:\n%s", first) + } + if !strings.Contains(first, tokens.GlyphFrameSide+" postgres") { + t.Errorf("the pane should open on the answer's own word, beside the seam:\n%s", first) + } + lab.press("down") + moved := lab.plain() + if !strings.Contains(moved, "One file in the repository") || strings.Contains(moved, "Rows already carry a foreign key") { + t.Errorf("walking down should put the second answer's evidence in the pane:\n%s", moved) + } + // AND THE LIST CARRIES NO CONSEQUENCE: what an answer leaves true is the + // pane's first labelled line, and the row beside the pane says it nowhere. + if strings.Count(moved, "the ledger travels with the checkout") != 1 || + !strings.Contains(moved, questionThenWord+"the ledger travels with the checkout") { + t.Errorf("what taking it leaves true belongs in the pane, labelled:\n%s", moved) + } +} + +// NARROWER THAN TWO PANES, THE EVIDENCE UNFOLDS UNDER THE POINTER'S ROW +// (preview-narrow pick B), and the row gives up what it leaves true to the +// unfolded evidence rather than saying it twice and cutting it the first time. +func TestNarrowerThanTwoPanesTheEvidenceUnfoldsUnderItsRow(t *testing.T) { + lab := newQuestionLab(t) + lab.a.width, lab.a.height = 96, 60 + lab.raise(demoQuestionReading()) + rows := questionPlainRows(lab.rows()) + pick, block, next := -1, -1, -1 + for at, row := range rows { + switch { + case strings.Contains(row, "1 postgres"): + pick = at + if strings.Contains(row, "share one connection") { + t.Errorf("the pointer's row should give its consequence to the evidence under it: %q", row) + } + case strings.Contains(row, "what it would look like"): + block = at + case strings.Contains(row, "2 sqlite beside the project"): + next = at + } + } + if !(pick >= 0 && pick < block && block < next) { + t.Fatalf("the evidence should unfold between the pointer's row and the next answer (%d %d %d):\n%s", + pick, block, next, strings.Join(rows, "\n")) + } + if !strings.Contains(strings.Join(rows, "\n"), questionThenWord+"the ledger and the rest of the project share one connection") { + t.Errorf("the unfolded evidence should carry what taking it leaves true:\n%s", strings.Join(rows, "\n")) + } + if strings.Contains(strings.Join(rows, "\n"), tokens.GlyphFrameSide+" postgres") { + t.Errorf("a narrow panel should not split:\n%s", strings.Join(rows, "\n")) + } +} + +// A PANEL NEVER TAKES MORE THAN HALF THE FRAME, split or unfolded, and a cut is +// said on its last row with its count and the way to the rest. +func TestAPanelNeverTakesMoreThanHalfTheFrame(t *testing.T) { + for _, width := range []int{140, 110, 96} { + lab := newQuestionLab(t) + lab.a.width, lab.a.height = width, 40 + lab.raise(tallEvidence()) + rows := questionPlainRows(lab.rows()) + if len(rows) > lab.a.questionPanelTall()+1 { + t.Errorf("at %d columns the panel took %d rows of a %d-row frame:\n%s", + width, len(rows), lab.a.height, strings.Join(rows, "\n")) + } + drawn := strings.Join(rows, "\n") + if !strings.Contains(drawn, questionMoreWord+questionKeyGap+lab.a.questionOpenFullWord()) { + t.Errorf("at %d columns the cut should say how much is left and how to reach it:\n%s", width, drawn) + } + for _, row := range lab.rows() { + if got := ansi.StringWidth(row); got > width { + t.Errorf("at %d columns a row is %d cells: %q", width, got, ansi.Strip(row)) + } + } + } +} + +// A PRESS ON THE LIST PRESSES AN ANSWER AND A PRESS ON THE EVIDENCE PRESSES +// NOTHING. The pane is prose and pictures, and a click aimed at a diagram that +// answered the question would be the worst press on this surface. +func TestAPressOnTheEvidenceBesideTheListPressesNothing(t *testing.T) { + lab := newQuestionLab(t) + lab.a.width, lab.a.height = 140, 60 + lab.raise(demoQuestionReading()) + seam := -1 + for _, row := range questionPlainRows(lab.rows()) { + if at := strings.Index(row, tokens.GlyphFrameSide+" postgres"); at >= 0 { + seam = ansi.StringWidth(row[:at]) + } + } + if seam < 0 { + t.Fatal("the panel did not split") + } + if len(lab.a.questionBands) == 0 { + t.Fatal("the list recorded no answers to press") + } + for _, band := range lab.a.questionBands { + if band.span.to > seam { + t.Errorf("answer %d's target reaches past the seam at %d into the evidence: %+v", band.at, seam, band.span) + } + } +} + +// THE ANSWER THE POINTER IS ON IS ALWAYS ON SCREEN, in one column and in two +// panes, however far down the arrows walk and however tall the evidence under +// it — the defect lane F measured on the old page, where the window moved only +// under the wheel. +func TestTheAnswerThePointerIsOnIsAlwaysOnScreen(t *testing.T) { + q := tallEvidence() + q.Form = session.FormRoom + for i := 0; i < 4; i++ { + q.Options = append(q.Options, session.AnswerOption{ + Key: itoa(len(q.Options) + 1), Label: "another store, number " + itoa(i), + Body: "A body long enough to take a row or two of the column.", Consequence: "something follows", + }) + } + for _, width := range []int{92, 140} { + a, _ := standingInAQuestionAt(t, q, width, 22) + for step := 0; step < len(q.Options); step++ { + want := questionOptionKeyAt(q, a.qroom.focus) + " " + strings.TrimSpace(q.Options[a.qroom.focus].Label) + if drawn := pageText(a); !strings.Contains(drawn, want) { + t.Fatalf("at %d columns the pointer's answer %q is off the screen:\n%s", width, want, drawn) + } + tapNamed(t, a, tea.KeyDown, 0) + } + for step := 0; step < len(q.Options); step++ { + tapNamed(t, a, tea.KeyUp, 0) + } + if drawn := pageText(a); !strings.Contains(drawn, "1 postgres") { + t.Fatalf("at %d columns walking back to the top left the first answer off the screen:\n%s", width, drawn) + } + } +} + +// THE PAGE STANDS AS TWO PANES FROM THE WIDTH TWO COLUMNS STAND AT — the body's +// width, which is the frame less any task column beside it — and the rule under +// the head and the rule in the foot meet one seam: the junction the foot draws is +// in the column the body's seam runs down. +func TestThePagesTwoRulesMeetOneSeam(t *testing.T) { + split := 0 + for _, width := range []int{96, 120, 140, 180} { + a, _ := standingInAQuestionAt(t, demoQuestionReading(), width, 40) + body := a.bodyWidth() + drawn := a.questionRoomRows(body) + rows := make([]string, len(drawn)) + for i, r := range drawn { + rows[i] = plain(r.text) + } + seam := a.qroom.seam + if want := body >= railSlimFloor; (seam >= 0) != want { + t.Fatalf("a body %d wide drew seam %d; two panes stand from %d", body, seam, railSlimFloor) + } + if seam < 0 { + continue + } + split++ + top := -1 + for at, row := range rows { + if strings.Contains(row, tokens.GlyphFrameTeeDown) { + top = at + if got := string([]rune(row)[seam]); got != tokens.GlyphFrameTeeDown { + t.Errorf("at %d columns the rule over the panes meets the seam at %d with %q", body, seam, got) + } + } + } + if top < 0 { + t.Fatalf("at %d columns there is no rule over the panes:\n%s", body, strings.Join(rows, "\n")) + } + for _, row := range rows[top+1:] { + if got := string([]rune(row)[seam]); got != tokens.GlyphFrameSide { + t.Errorf("at %d columns the seam breaks: %q", body, row) + break + } + } + foot := []rune(plain(a.questionFootRows(width)[0])) + if got := string(foot[seam]); got != tokens.GlyphFrameTeeUp { + t.Errorf("at %d columns the foot's rule meets the seam with %q: %q", body, got, string(foot)) + } + } + if split == 0 { + t.Fatal("no width drew two panes") + } +} + +// THE PAGE'S FOOT IS ITS OWN RULE, so the legend and its blank are not drawn a +// row above it while the page is up — two rules one row apart, the upper one +// naming keys the page does not take — and the geometry that counts the +// conversation's rows agrees with the frame that draws them. +func TestThePagesFootReplacesTheLegend(t *testing.T) { + a, _ := standingInAQuestionAt(t, demoQuestionReading(), 140, 40) + chrome, _, _, _ := a.chrome(a.width) + for _, row := range chrome { + if strings.Contains(plain(row), "/ commands") { + t.Errorf("the conversation's legend is drawn over the page's foot: %q", plain(row)) + } + } + if got, want := len(chrome), a.chromeHeight(); got != want { + t.Errorf("the frame draws %d chrome rows and the geometry charges %d", got, want) + } +} + +// THE KEYS THE PAGE DID NOT DRAW ARE STILL THE SURFACE'S. The page takes the +// keys it prints on its foot and nothing else — so `ctrl+g`, which stows the +// task column, works while a question is open on the page, and the cells the +// column gives back are what the split is measured in +// ([app.questionRoomBeside] reads [app.bodyWidth]). +func TestTheColumnsOwnKeyStillWorksWhileThePageIsUp(t *testing.T) { + a, _ := standingInAQuestionAt(t, demoQuestionReading(), 120, 40) + if _, took := tapNamed(t, a, 'g', tea.ModCtrl); took { + t.Fatal("the page took ctrl+g: a key it never printed is a key the surface under it owes") + } +} + +// A CLOCK THAT WILL ANSWER SAYS SO IN EVERY DRAWING, AND AT THE WIDTH IT WAS +// WRITTEN FOR. #954 put that sentence inside the panel's frame, which is right; +// the split and the page lay their own rows, so each has to place it — and +// neither pane will do: a column half the panel wide cuts it mid-word, and the +// pane beside the list is ONE answer's case, where a sentence about the whole +// decision reads as that answer's. So it crosses the seam on the panel and +// stands with the reason on the page. +func TestTheClockSaysWhatCanBeDoneAboutItAtTheFramesOwnWidth(t *testing.T) { + clocked := func() session.Question { + q := demoQuestionReading() + q.Policy = session.Policy{Kind: session.PolicyRecommendThenAuto, After: 30 * time.Second} + return q + } + lab := newQuestionLab(t) + lab.a.width, lab.a.height = 140, 60 + q := clocked() + q.Deadline = lab.a.now().Add(30 * time.Second) + lab.raise(q) + rows := questionPlainRows(lab.rows()) + drawn := strings.Join(rows, "\n") + if !strings.Contains(drawn, questionClockAsideWord) { + t.Fatalf("the split dropped the clock's own line:\n%s", drawn) + } + // AND IT CROSSES THE SEAM, WHOLE. A row inside a pane carries three of the + // frame's sides — its own two edges and the seam between the panes — and + // this row has two, which is the sentence at the frame's own width. + for _, row := range rows { + if !strings.Contains(row, questionClockAsideWord) { + continue + } + if got := strings.Count(row, tokens.GlyphFrameSide); got != 2 { + t.Errorf("the clock's line is inside a pane (%d frame sides on the row): %q", got, row) + } + } + + page, _ := standingInAQuestionAt(t, clocked(), 140, 40) + page.qroom.head.question.Deadline = page.now().Add(30 * time.Second) + page.questionRoomTouched() + if got := pageText(page); !strings.Contains(got, questionClockAsideWord) { + t.Errorf("the page dropped the clock's own line:\n%s", got) + } +} + +// TestThePagesTwoRulesEndInTheSameColumnWithTheTaskColumnUp is the drawing bug +// the whole screens table hid: every split shot stowed the column, and the e2e +// scenario stows it too. +// +// THE PAGE'S TWO RULES ARE TWO EDGES OF ONE OBJECT. The body draws at +// [app.bodyWidth], which is charged for the task column, and the foot is chrome +// drawn at the full frame width — so the top rule stopped at the body's edge +// while the foot's ran on under the rail. It only shows where the body is still +// wide enough to split WITH the column standing, which is a 140-cell terminal. +func TestThePagesTwoRulesEndInTheSameColumnWithTheTaskColumnUp(t *testing.T) { + a, _ := standingInAQuestionAt(t, demoQuestionReading(), 140, 40) + if a.railAway { + t.Fatal("this test needs the task column UP; the lab stowed it") + } + body := a.bodyWidth() + if !a.questionRoomBeside(body) { + t.Fatalf("the body is %d cells and did not split; the case this test is about cannot arise", body) + } + rows := a.questionRoomRows(body) + top := "" + for _, r := range rows { + if strings.Contains(plain(r.text), tokens.GlyphFrameTeeDown) { + top = plain(r.text) + break + } + } + if top == "" { + t.Fatalf("the page drew no top rule at %d cells:\n%s", body, pageText(a)) + } + foot := plain(a.questionFootRows(a.width)[0]) + if ansi.StringWidth(top) != ansi.StringWidth(foot) { + t.Errorf("the page's rules end in different columns: top %d, foot %d\ntop: %q\nfoot: %q", + ansi.StringWidth(top), ansi.StringWidth(foot), top, foot) + } + // AND THE JUNCTIONS STAND IN ONE COLUMN, which is the seam the body drew + // rather than one worked out a second time. Measured in CELLS and not bytes: + // every one of these glyphs is three bytes wide and one column wide. + column := func(row, glyph string) int { + at := strings.Index(row, glyph) + if at < 0 { + return -1 + } + return ansi.StringWidth(row[:at]) + } + if got, want := column(foot, tokens.GlyphFrameTeeUp), column(top, tokens.GlyphFrameTeeDown); got != want { + t.Errorf("the junctions are in different columns: top %d, foot %d\ntop: %q\nfoot: %q", want, got, top, foot) + } + if a.questionRoomSeam() != a.qroom.seam { + t.Errorf("the foot's seam (%d) is not the seam the body stored (%d)", a.questionRoomSeam(), a.qroom.seam) + } +} + +// TestACardDrawingAQuestionsAnswersUnfoldsNoEvidence holds the bound on the door +// two other surfaces draw these rows through. +// +// A CARD CANNOT GROW BY EVERY DIAGRAM AN ANSWER BROUGHT. The task record's +// landing card ([app.taskRecordLandingRows]) and home's errand pane both draw +// [app.questionPanelBody], and neither owns its height — so the split and the +// unfold are the panel's and the page's, which do. +func TestACardDrawingAQuestionsAnswersUnfoldsNoEvidence(t *testing.T) { + lab := newQuestionLab(t) + q := demoQuestionReading() + shown := questionShown{question: q, shown: lab.a.now(), pick: questionPointerStart(q)} + body := strings.Join(questionPlainRows(lab.a.questionPanelBody(shown, 96)), "\n") + // The case's own labels are what the evidence draws, wherever it is drawn. + for _, word := range []string{questionThenWord, questionWhyWord, questionConfidenceLabel} { + if strings.Contains(body, strings.TrimSpace(word)) { + t.Errorf("a card's rows carry an answer's case (%q):\n%s", word, body) + } + } + // AND EVERY ANSWER IS STILL THERE — bounding the evidence may not cost the + // list the thing it is for. + for _, option := range q.Options { + if !strings.Contains(body, strings.TrimSpace(option.Label)) { + t.Errorf("a card's rows dropped the answer %q:\n%s", option.Label, body) + } + } +} diff --git a/internal/tui3/questionevidencelaw_test.go b/internal/tui3/questionevidencelaw_test.go new file mode 100644 index 0000000000..cbe1e7ee1d --- /dev/null +++ b/internal/tui3/questionevidencelaw_test.go @@ -0,0 +1,109 @@ +package tui3 + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "strings" + "testing" +) + +// ONE ACCOUNT OF AN ANSWER'S CASE, AND ONE RENDERER FOR WHAT IT DREW. +// +// An answer's evidence is drawn in four places — the pane beside a wide panel's +// list, the fold under a narrow panel's pointer, the page's right pane and the +// page's one column — and before questionevidence.go each place that drew the +// labelled case spelled it itself: the page wrote `then ·` and `would switch if` +// on its own and the panel wrote its one-line case on its own, and the two +// disagreed about whether a model's `If you…` was joined onto `would switch if` +// twice. These laws hold the seam shut: +// +// - the words that label a case line are READ in questionevidence.go and +// nowhere else (their declarations stand where the page's other words are +// spelled), so a new drawing of the case has to go through [questionCase]; +// - the five block renderers are called by [app.questionBlockRows] and by each +// other in questionblocks.go, and from no other file, so a new drawing of +// evidence cannot grow a diagram painter of its own. + +// questionCaseWords are the labels of an answer's case. +var questionCaseWords = []string{"questionThenWord", "questionWhyWord", "questionWouldSwitchWord", "questionConfidenceLabel"} + +// questionBlockPainters are the renderers behind [app.questionBlockRows]. +var questionBlockPainters = []string{ + "questionDiagramLines", "questionTableLines", "questionDiffLines", + "questionImageLines", "questionLayoutLines", +} + +func TestAnAnswersCaseIsSpelledInOnePlace(t *testing.T) { + uses := questionIdentUses(t, append(append([]string{}, questionCaseWords...), questionBlockPainters...)) + for _, word := range questionCaseWords { + for _, file := range uses[word] { + if file != "questionevidence.go" { + t.Errorf("%s reads %s: an answer's case is drawn through questionCase in questionevidence.go "+ + "and nowhere else, so every drawing of it agrees", file, word) + } + } + } + for _, painter := range questionBlockPainters { + for _, file := range uses[painter] { + if file != "questionblocks.go" { + t.Errorf("%s calls %s: every block is drawn through questionBlockRows, "+ + "so a diagram looks the same wherever a question shows one", file, painter) + } + } + } +} + +// questionIdentUses maps each named identifier to the files (by base name) that +// USE it — a declaration of the name is not a use. +func questionIdentUses(t *testing.T, names []string) map[string][]string { + t.Helper() + want := map[string]bool{} + for _, name := range names { + want[name] = true + } + files, err := filepath.Glob("*.go") + if err != nil { + t.Fatal(err) + } + out := map[string][]string{} + fset := token.NewFileSet() + for _, path := range files { + if strings.HasSuffix(path, "_test.go") { + continue + } + src, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + file, err := parser.ParseFile(fset, path, src, 0) + if err != nil { + t.Fatal(err) + } + declared := map[*ast.Ident]bool{} + ast.Inspect(file, func(n ast.Node) bool { + switch d := n.(type) { + case *ast.ValueSpec: + for _, name := range d.Names { + declared[name] = true + } + case *ast.FuncDecl: + declared[d.Name] = true + } + return true + }) + seen := map[string]bool{} + ast.Inspect(file, func(n ast.Node) bool { + id, ok := n.(*ast.Ident) + if !ok || !want[id.Name] || declared[id] || seen[id.Name] { + return true + } + seen[id.Name] = true + out[id.Name] = append(out[id.Name], filepath.Base(path)) + return true + }) + } + return out +} diff --git a/internal/tui3/questionhand_test.go b/internal/tui3/questionhand_test.go index f8314f99d2..ede13bcc3d 100644 --- a/internal/tui3/questionhand_test.go +++ b/internal/tui3/questionhand_test.go @@ -197,21 +197,21 @@ func TestThePageScrollsToTheAnswerTheArrowsWalkTo(t *testing.T) { } a.openQuestionRoom(head) a.qroom.shown = a.qroom.shown.Add(-time.Second) - // Open every answer so the page is taller than the window, which is the - // shape the defect needs and the shape a page worth opening has. + // Walk to the last answer, which is what makes the page taller than the + // window — the answer the pointer is on carries its evidence, so a page + // worth opening does not fit and this is the shape the defect needs. for range len(q.Options) { a.questionMoveFocus(1) - a.qroom.open[a.qroom.focus] = true } rows := a.questionRoomRows(a.bodyWidth()) if len(rows) <= a.viewHeight() { t.Skipf("the page fits the window (%d rows in %d), so there is nothing to scroll", len(rows), a.viewHeight()) } a.questionMoveFocus(0) - offset := a.questionRoomOffsetFor(len(rows), a.viewHeight()) + offset := a.qroom.offset last := -1 - for at, of := range a.qroom.spots { - if of == a.qroom.focus { + for at, spot := range a.qroom.spots { + if spot.at == a.qroom.focus { last = at } } diff --git a/internal/tui3/questioninput_test.go b/internal/tui3/questioninput_test.go index a141abbd2c..6e3e3718e5 100644 --- a/internal/tui3/questioninput_test.go +++ b/internal/tui3/questioninput_test.go @@ -318,12 +318,12 @@ func TestADiffBlockWearsTheDiffGlyphs(t *testing.T) { } } -// A TWO-PANE LAYOUT IS SIDE BY SIDE ABOVE A HUNDRED COLUMNS AND STACKED BELOW, -// which is DESIGN.md's own number: a pane cut to thirty characters is a pane -// that has stopped being pre-formatted. -func TestALayoutStacksOnANarrowPage(t *testing.T) { - a, _ := standingInAQuestion(t, demoQuestionLayout()) - a.width = 120 +// A TWO-PANE LAYOUT IS SIDE BY SIDE WHERE BOTH PANES FIT WHOLE AND STACKED WHERE +// THEY DO NOT: a pane cut to fit is a pane that has stopped being pre-formatted. +// It used to be a hundred columns of PAGE, and drawn in the evidence pane beside +// a list the same block had seventy — and stacked for no reason anybody could see. +func TestALayoutStacksWhereItsPanesWillNotBothFitWhole(t *testing.T) { + a, _ := standingInAQuestionAt(t, demoQuestionLayout(), 120, 40) wide := pageText(a) sideBySide := false for _, line := range strings.Split(wide, "\n") { @@ -332,14 +332,14 @@ func TestALayoutStacksOnANarrowPage(t *testing.T) { } } if !sideBySide { - t.Errorf("above a hundred columns the panes stand side by side:\n%s", wide) + t.Errorf("in a pane wide enough for both, the panes stand side by side:\n%s", wide) } - a.width = 80 + a.width = 40 a.qroom.dirty = true narrow := pageText(a) for _, line := range strings.Split(narrow, "\n") { if strings.Contains(line, "deepseek-v4-flash") && strings.Contains(line, "deepseek-v4-fl...") { - t.Errorf("under a hundred columns the panes stack:\n%s", narrow) + t.Errorf("where a pane would be cut, the panes stack:\n%s", narrow) } } } diff --git a/internal/tui3/questionkeys.go b/internal/tui3/questionkeys.go index 9ac7b32169..de3800fb7a 100644 --- a/internal/tui3/questionkeys.go +++ b/internal/tui3/questionkeys.go @@ -192,6 +192,11 @@ const ( // reads ([questionAxes]). THE EMPTINESS LAW: `x` on a question with nothing // to compare would open a table with no rows in it. needCompare + // needBeside is a page drawn as two panes with the pointer on an answer and + // the arrows still the list's: `→` has a pane to move into. + needBeside + // needReading is the arrows handed to the evidence pane (`→`). + needReading // needScope is a question that offered more than one LIFETIME for its answer // and can be taken back (questionscope.go). A question with one lifetime has // nothing to cycle, and an irreversible one is asked every time. @@ -264,6 +269,12 @@ const ( // [questionWalkKey]'s reason: `shift+↑↓ order them` is one affordance. The // routing reads `shift+up` and `shift+down` individually. questionOrderKey = "shift+↑↓" + // questionDetailKey and questionAnswersKey are the page's two side arrows + // SPELLED ALONE, for [questionBackKey]'s reason: on a page of two panes `→` + // goes into the evidence and `←` comes back out, which are two different + // acts rather than one pair. The routing reads `right` and `left`. + questionDetailKey = "→" + questionAnswersKey = "←" ) // questionKeys IS THE TABLE. Order is the order the answers row prints them in, @@ -394,12 +405,18 @@ var questionKeys = []questionVerb{ // [needMoves] states: `pick` walks a cursor between two answers and `move it` // changes the answer itself, and the two are never true at once. {key: questionWalkKey, word: "move it", forms: formsCard | formsRoom, needs: needMoves, tier: keyPrimary}, - // INSIDE THE ROOM `o` OPENS AN ANSWER RATHER THAN THE PAGE, which is why it - // is a second row rather than a second word: `[o] open it` on a card is a - // promise about a page, and repeating that promise on the page itself would - // be an offer to go where somebody already is. It is the first thing a narrow - // row gives up, because every answer already wears its own ▸/▾. - {key: questionOpenKey, word: "open this answer", forms: formsRoom, needs: needOptions, giveUp: 9}, + // THE PAGE'S SIDEWAYS WALK (questionpage.go). `→` hands the arrows to the + // evidence beside the list, so a diagram taller than the page is read to its + // end; while they are the pane's, `↑↓` scroll it and `←` hands them back. + // They are rows of their own rather than second words on the walk for the + // reason [needMoves] states: `choose` moves a pointer and `scroll` moves a + // window, and a row that said one while the keys did the other is the defect + // a key row exists to prevent. `→` is offered only where there IS a pane + // beside the list — a page of one column unfolds the evidence under the + // pointer and has nothing to move into. + {key: questionDetailKey, word: "detail", forms: formsRoom, needs: needBeside, tier: keyPrimary, giveUp: 3}, + {key: questionWalkDownKey, word: "scroll", forms: formsRoom, needs: needReading, tier: keyPrimary}, + {key: questionAnswersKey, word: "back to the answers", forms: formsRoom, needs: needReading, tier: keyPrimary}, } // ── the one key row ───────────────────────────────────────────────────────── @@ -599,10 +616,14 @@ func (a *app) questionOffers(q questionShown, need questionNeed) bool { // EVERY QUESTION WITH ANSWERS HAS A POINTER AND ENTER TAKES IT // ([questionPointerStart]); a question with none written down offers // enter only when the asker recommended something. - if room := a.qroom; room != nil && room.head.token() == q.token() { - // THE ROOM'S ENTER SENDS WHAT THE ROOM HAS PICKED, and its foot - // says `nothing chosen yet` until something is; enter is offered - // there only when there is something for it to send. + if room := a.questionPageOf(q); room != nil { + // THE PAGE'S ENTER SENDS WHAT ITS FOOT SAYS ([app.questionRoomSends]), + // and is offered only where that is something. A shape is sent + // whole when it reads right, and says so in the foot instead. + if room.input.kind == session.InputNone { + keys, words := a.questionRoomSends() + return len(keys) > 0 || words != "" + } return len(room.picked) > 0 || (q.question.Pick != nil && strings.TrimSpace(q.question.Pick.Key) != "") } if q.question.Input.Kind == session.InputText { @@ -627,7 +648,19 @@ func (a *app) questionOffers(q questionShown, need questionNeed) bool { case needDial: return q.question.Kind != "" && q.question.Asker.Kind != session.AskerSurface && a.questionOffers(q, needHands) + case needBeside: + room := a.questionPageOf(q) + return room != nil && !room.reading && room.focus < len(q.question.Options) && + a.questionRoomBeside(a.bodyWidth()) + case needReading: + room := a.questionPageOf(q) + return room != nil && room.reading case needWalk: + // WHILE THE ARROWS ARE THE EVIDENCE PANE'S they scroll it, and the row + // that says so is [needReading]'s. + if room := a.questionPageOf(q); room != nil && room.reading { + return false + } // Every question with answers has the pointer ([questionPointerStart]); // a checklist walks its own ticks with the same keys and says so on // its `next row` line instead, and where `←→` move a hole @@ -686,6 +719,16 @@ func (a *app) questionOffers(q questionShown, need questionNeed) bool { return true } +// questionPageOf is the page, where the page is open over THIS question, and nil +// everywhere else — a condition about the page's pointer is a condition about +// no other drawing of the question. +func (a *app) questionPageOf(q questionShown) *questionRoom { + if room := a.qroom; room != nil && room.head.token() == q.token() { + return room + } + return nil +} + // questionHandsOnly reports whether this is a decision NOBODY BUT A PERSON MAY // EVER MAKE. // diff --git a/internal/tui3/questionpage.go b/internal/tui3/questionpage.go new file mode 100644 index 0000000000..21a77aa847 --- /dev/null +++ b/internal/tui3/questionpage.go @@ -0,0 +1,643 @@ +package tui3 + +import ( + "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" +) + +// ── THE PAGE, DRAWN ───────────────────────────────────────────────────────── +// +// `o` OPENS THE QUESTION AS TWO PANES (owner ruling 2026-09-11, page pick A): +// the question and its reason across the top, the answers in a list on the left +// with what the asker showed for the whole decision under them, and the evidence +// of the answer the pointer is on in a pane on the right — its word, what it +// means, what taking it leaves true, why the asker would take it, what would +// change its mind, how sure it is, and everything it drew. +// +// ? Which storage for the session index? model asks · the turn waits on it +// The index needs somewhere to live between launches. +// +// ────────────────────────────────────────┬─────────────────────────────────────────── +// ▸ 1 SQLite ◆ recommended │ SQLite +// 2 JSONL │ one file beside the conversation +// 3 BoltDB │ +// 4 something else… │ then · the index survives a crash mid-write +// │ why this one · already a dependency +// what it showed you │ would switch if you need it greppable by hand +// store.go:88-120 the current index │ confidence · fairly sure +// │ +// │ schema +// │ sessions(id, title, updated) +// ────────────────────────────────────────┴─── answering 1 SQLite ───────────────────── +// ↑↓ choose · enter take it · esc later · → detail c change · x compare · ? ask back +// +// IT REPLACED A PAGE OF FOLDING SECTIONS, and the reason is the thing a person +// does on this page: WEIGH one answer against the next. The old page stacked +// every open answer's paragraph, asides, diagrams and notes one under another +// in a single column, so the second answer's evidence was below the first's +// and comparing them meant scrolling between two places that could not both be +// on screen. With the list fixed on the left and the evidence following the +// pointer on the right, `↓` IS the comparison: the pane changes under the eye +// and nothing else moves. +// +// NARROWER THAN TWO PANES ([besideFits]) IT IS ONE COLUMN, the panel's own +// preview-narrow shape: every answer a row, and the one the pointer is on +// unfolded under its row. The compare table (`x`) and an answer that is a SHAPE +// — holes, a checklist, a run of pairs, a dial — take the column at any width: +// the table lays every answer against every other and needs the whole of it, +// and a shape has no evidence per row to put beside it. +// +// ── TWO WINDOWS, AND THE POINTER IS ALWAYS IN ONE OF THEM ── +// +// The head is pinned; under the rule, the list (or the one column) and the +// evidence pane each keep an offset of their own. The list's window follows the +// pointer ([app.questionRoomShowFocus]) — the answer a person walked to is on +// screen, always — and the evidence pane starts at its top every time the +// pointer moves, because what it shows has just changed. `→` hands the arrows to +// the pane so a long diagram can be read to its end, `←` hands them back, and the +// wheel scrolls whichever pane it is over. + +// questionPageOtherWord is what the evidence pane says while the pointer is on +// `something else…`: the page's box is the message box below it, so the row is +// not a box of its own the way the panel's is ([app.questionPanelOther]), and the +// pane says where the words go. +const questionPageOtherWord = "type your own answer in the box below, then enter" + +// questionSpot is what a press on one row of the page reaches: which answer, +// and the columns it answers in. A row that reaches nothing is at -1. +type questionSpot struct { + at int + from, to int +} + +// questionPageLayout is the page laid out at one size, before either window is +// applied: the pinned head, where the seam falls, the left pane (or the one +// column) with the answer each of its rows presses, and the evidence pane. +type questionPageLayout struct { + head []string + // seam is the column the two panes meet at, or -1 for one column. + seam int + list []string + listAt []int + pane []string + // region is how many rows the two windows have under the rule. + region int +} + +// questionRoomBeside reports whether the page lays its answers beside the +// evidence of the one the pointer is on, or in one column. +func (a *app) questionRoomBeside(width int) bool { + room := a.qroom + return room != nil && besideFits(width) && !room.compare && + room.input.kind == session.InputNone && len(room.head.question.Options) > 0 +} + +// questionRoomTakesOther reports whether the page's list ends in `something +// else…`: exactly where the panel's does ([app.questionTakesOther]), so the list +// a person opened is the list they were looking at. +func (a *app) questionRoomTakesOther() bool { + room := a.qroom + return room != nil && room.input.kind == session.InputNone && a.questionTakesOther(room.head) +} + +// questionRoomWalk is how many rows the page's pointer walks. +func (a *app) questionRoomWalk() int { + room := a.qroom + if room == nil { + return 0 + } + if room.input.kind != session.InputNone { + return room.input.count() + } + n := len(room.head.question.Options) + if a.questionRoomTakesOther() { + n++ + } + return n +} + +// questionRoomShown is the question as the page's pointer sees it: the object +// the block holds, with the pointer standing where the page's does, so one +// answer row is painted by one painter wherever it is drawn +// ([app.questionPanelOption]). +func (a *app) questionRoomShown() questionShown { + shown := a.qroom.head + shown.pick = a.qroom.focus + return shown +} + +// questionRoomLay lays the page out at one size. +func (a *app) questionRoomLay(width, height int) questionPageLayout { + lay := questionPageLayout{seam: -1} + lay.head = a.questionPageHead(width) + lay.region = max(height-len(lay.head)-1, 0) + if !a.questionRoomBeside(width) { + lay.list, lay.listAt = a.questionPageColumn(width) + return lay + } + left, right := besideSplit(width, a.questionPageListWant(width)) + lay.seam = left + lay.list, lay.listAt = a.questionPageList(left) + lay.pane = a.questionPagePane(right) + return lay +} + +// questionRoomView is the body region at one size: exactly the rows it draws, +// and for each of them what a press on it reaches. +func (a *app) questionRoomView(width, height int) []row { + room := a.qroom + if room == nil || width <= 0 || height <= 0 { + return nil + } + if a.questionDrainReplies() { + room.dirty = true + } + if !room.dirty && room.width == width && room.height == height && room.rows != nil { + return room.rows + } + lay := a.questionRoomLay(width, height) + out := make([]row, 0, height) + spots := make([]questionSpot, 0, height) + add := func(text string, spot questionSpot) { + if len(out) < height { + out = append(out, row{text: text, entry: -1}) + spots = append(spots, spot) + } + } + none := questionSpot{at: -1} + for _, line := range lay.head { + add(line, none) + } + add(besideRule(a.pal, width, lay.seam, tokens.GFrameTeeDown, ""), none) + from := questionWindowStart(room.offset, len(lay.list), lay.region) + list := lay.list[from:min(from+lay.region, len(lay.list))] + listAt := lay.listAt[from:min(from+lay.region, len(lay.listAt))] + if lay.seam < 0 { + for i, line := range list { + add(line, questionSpot{at: listAt[i], from: 0, to: width}) + } + } else { + // BOTH PANES FILL THE REGION, so the seam runs unbroken from the rule + // above to the rule in the foot below, and the page is one object split + // in two rather than two lists that happen to be near each other. + detail := questionWindowStart(room.detail, len(lay.pane), lay.region) + pane := lay.pane[detail:min(detail+lay.region, len(lay.pane))] + for i, line := range besides(a.pal, questionPadTo(list, lay.region), pane, lay.seam, width) { + spot := none + if i < len(listAt) { + spot = questionSpot{at: listAt[i], from: 0, to: lay.seam} + } + add(line, spot) + } + } + room.rows, room.spots, room.width, room.height, room.dirty = out, spots, width, height, false + room.seam = lay.seam + return out +} + +// questionWindowStart clamps a window's offset to the rows it is over, so a +// window never opens past its own end. +func questionWindowStart(offset, total, height int) int { + return questionClamp(offset, 0, max(total-height, 0)) +} + +// questionPadTo pads a column with blank rows to `n`. +func questionPadTo(rows []string, n int) []string { + for len(rows) < n { + rows = append(rows, "") + } + return rows +} + +// questionPageHead is what stands above the rule: the question with its mark +// and — at the right — who is asking and what is waiting on it, the asker's +// reason under it, and one blank row. The row of air above the head is the +// frame's own, under the tabs. +// +// THE FOUR FACTS DESIGN.md WRITES FOR THIS PAGE ARE ALL HERE: asked by · why now · +// what is paused on it · what goes on without it ([questionBlockingWord] carries +// the last two). The emptiness law governs each of them. +func (a *app) questionPageHead(width int) []string { + q := a.qroom.head.question + inner := max(width-2*len(questionIndent), 1) + aside := questionPageAside(q) + asideW := ansi.StringWidth(aside) + out := make([]string, 0, 6) + lines := wrap(strings.TrimSpace(q.Head), max(inner-2, 1)) + // The aside stands beside the head where the head is one line with room + // left for it, and on a dim row of its own under the reason where it is not + // — the head is never cut to make room for its own attribution. + beside := aside != "" && len(lines) == 1 && ansi.StringWidth(lines[0])+2+2+asideW <= inner + for i, line := range lines { + lead := " " + if i == 0 { + lead = a.pal.warnBold(a.icon(tokens.GNeedsHuman)) + " " + } + text := questionIndent + lead + a.pal.ink(line) + if i == 0 && beside { + text = questionRightAlign(text, a.pal.dim(aside), width-len(questionIndent)) + } + out = append(out, text) + } + if reason := strings.TrimSpace(q.Reason); reason != "" { + for _, line := range wrap(reason, inner) { + out = append(out, questionIndent+a.pal.dim(line)) + } + } + if aside != "" && !beside { + for _, line := range wrap(aside, inner) { + out = append(out, questionIndent+a.pal.dim(line)) + } + } + // AND A CLOCK THAT WILL ANSWER SAYS WHAT A PERSON CAN DO ABOUT IT (#954). + // On the page it belongs with the reason and the attribution, above the + // panes: it is a fact about the QUESTION, so in the left pane it would be + // cut by a column half the page wide and in the right pane it would read as + // the pointer's own answer. + if clock := a.questionClockAside(a.qroom.head, inner); clock != "" { + out = append(out, questionIndent+clock) + } + return append(out, "") +} + +// questionPageAside is the head's attribution: who is asking, as a verb, and +// what is stopped on the answer and what is not. +func questionPageAside(q session.Question) string { + parts := make([]string, 0, 2) + if asker := questionAskerWord(q.Asker); asker != "" { + parts = append(parts, asker+questionAsksWord) + } + if blocking := questionBlockingWord(q.Blocking); blocking != "" { + parts = append(parts, blocking) + } + return strings.Join(parts, questionSep) +} + +// questionRightAlign puts an aside at the right edge of a row, or leaves the row +// alone where there is no room for it. It measures the PAINTED strings, because +// that is what the terminal draws. +func questionRightAlign(left, right string, width int) string { + gap := width - ansi.StringWidth(left) - ansi.StringWidth(right) + if gap < 2 { + return left + } + return left + strings.Repeat(" ", gap) + right +} + +// questionPageListWant is how wide the left pane would like to be: its widest +// answer row, or the widest thing the asker showed for the whole decision. +// [besideSplit] holds it to half the page. +func (a *app) questionPageListWant(width int) int { + q := a.qroom.head.question + room := max(width/2, 1) + lead := questionPanelLead(q, room) + gutter := len(questionPanelGap) + len(questionPanelGap) + // Every answer's word stands in one column as wide as the widest of them + // ([app.questionPanelRow]'s pad), and the pick's mark with its word stands + // at the right edge of the list — so the list asks for both together, in the + // one account of it ([app.questionListWant]). The third gap is the page's + // own: it indents its list one gap inside the seam where the panel does not. + want := gutter + len(questionPanelGap) + a.questionListWant(q, lead) + if a.questionRoomTakesOther() { + want = max(want, gutter+lead+ansi.StringWidth(questionPanelOtherWord)+len(questionPanelGap)) + } + for _, block := range q.Attach { + want = max(want, len(questionIndent)+questionBlockWant(block)+1) + } + return want +} + +// questionBlockWant is how wide a block would like to be drawn: its widest +// line, for the kinds whose lines are the drawing. Prose wraps to whatever it +// is given, so it asks for nothing. +func questionBlockWant(block session.Block) int { + widest := ansi.StringWidth(strings.TrimSpace(block.Title)) + switch block.Kind { + case session.BlockDiagram, session.BlockDiff: + for _, line := range strings.Split(block.Body, "\n") { + widest = max(widest, ansi.StringWidth(expandTabs(line))) + } + case session.BlockTable: + for _, row := range block.Rows { + w := 0 + for _, cell := range row { + w += ansi.StringWidth(strings.TrimSpace(cell)) + 2 + } + widest = max(widest, w) + } + case session.BlockLayout: + w := 0 + for _, pane := range block.Rows { + w += questionPaneWidest(pane) + 1 + } + widest = max(widest, w) + } + return widest +} + +// questionPageList is the left pane: the answers, then what the asker showed for +// the whole decision under its one dim title. +// +// THE ROWS ARE THE PANEL'S OWN ([app.questionPanelOption]), so the list a person +// opened the page from and the list on the page are one list drawn twice: the +// same pointer, the same key, the same word, the same `◆ recommended`. +func (a *app) questionPageList(left int) ([]string, []int) { + room := a.qroom + q := a.questionRoomShown() + inner := max(left-len(questionPanelGap), 1) + listRoom := max(inner-2*len(questionPanelGap), 1) + pad := questionLabelPad(q.question.Options, listRoom) + out := make([]string, 0, len(q.question.Options)+8) + at := make([]int, 0, cap(out)) + for i, option := range q.question.Options { + for _, line := range a.questionPanelOption(q, i, option, pad, listRoom, questionBeside, questionHoverPage) { + out, at = append(out, questionPanelGap+line), append(at, i) + } + } + if a.questionRoomTakesOther() { + other := questionOtherAt(q.question) + focused := room.focus == other + for _, line := range a.questionPanelRow(q, itoa(other+1), "", questionPanelOtherWord, + "", "", pad, listRoom, focused, a.questionHovering(questionHoverPage, other)) { + out, at = append(out, questionPanelGap+line), append(at, other) + } + } + for _, line := range a.questionPageAttach(left) { + out, at = append(out, line), append(at, -1) + } + return out, at +} + +// questionAttachIndent is where the question's OWN evidence sits: under its one +// dim title and one step in from it, so a block reads as belonging to the title +// above it rather than as a second title beside it. +const questionAttachIndent = " " + +// questionPageAttach is what the asker showed for the WHOLE decision, as opposed +// to what hangs off one answer: one dim title, and the blocks under it. +// +// IT IS UNDER THE LIST AND NOT IN THE PANE, because the pane changes with the +// pointer and this does not — it is the same evidence whichever answer is being +// weighed, so it stands with the thing that stays still. +func (a *app) questionPageAttach(width int) []string { + room := a.qroom + if room == nil || len(room.head.question.Attach) == 0 { + return nil + } + inner := max(width-len(questionIndent), 1) + out := []string{"", questionIndent + a.pal.dim(fit(questionAttachWord, inner))} + for i, block := range room.head.question.Attach { + if i > 0 { + out = append(out, "") + } + out = append(out, a.questionBlockRows(block, len(questionAttachIndent), width)...) + } + return out +} + +// questionPagePane is the right pane: the evidence of the answer the pointer is +// on, and the person's own notes on it — the comment `c` wrote and the one +// thing `?` asked. +func (a *app) questionPagePane(width int) []string { + room := a.qroom + inner := max(width-2*len(questionPanelGap), 1) + q := room.head.question + var rows []string + if room.focus >= len(q.Options) { + rows = []string{a.pal.dim(fit(questionPageOtherWord, inner))} + } else { + rows = a.questionEvidenceRows(q, room.focus, inner, true) + if notes := a.questionNoteRows(questionOptionKeyAt(q, room.focus), 0, inner); len(notes) > 0 { + rows = append(append(rows, ""), notes...) + } + } + out := make([]string, 0, len(rows)) + for _, line := range rows { + out = append(out, questionPanelGap+line) + } + return out +} + +// questionPageColumn is the page as one column: the compare table, a shape, or +// the answers with the one the pointer is on unfolded under its row, and what +// the asker showed for the whole decision after them. +func (a *app) questionPageColumn(width int) ([]string, []int) { + room := a.qroom + out := make([]string, 0, 32) + at := make([]int, 0, 32) + prose := func(lines []string) { + for _, line := range lines { + out, at = append(out, line), append(at, -1) + } + } + switch { + case room.compare: + prose(a.questionCompareRows(width)) + return out, at + case room.input.kind != session.InputNone: + // A STRUCTURED INPUT REPLACES THE ANSWERS AND NEVER SITS UNDER THEM. + // DESIGN.md's ladder puts structured input one rung BELOW the question — + // blanks, a checklist, this-or-this, a dial are the answer itself, not a + // garnish on a list of answers. + prose(a.questionInputRows(width)) + prose(a.questionPageAttach(width)) + return out, at + } + q := a.questionRoomShown() + inner := max(width-len(questionPanelGap), 1) + listRoom := max(inner-2*len(questionPanelGap), 1) + pad := questionLabelPad(q.question.Options, listRoom) + lead := len(questionPanelGap) + questionPanelLead(q.question, listRoom) + indent := strings.Repeat(" ", lead) + for i, option := range q.question.Options { + for _, line := range a.questionPanelOption(q, i, option, pad, listRoom, questionRowOnly, questionHoverPage) { + out, at = append(out, questionPanelGap+line), append(at, i) + } + if i != room.focus { + continue + } + // UNDER THE POINTER, THE WHOLE OF IT: the evidence the pane would show, + // less the word its own row is carrying — and, here only, the person's + // own notes on it. The evidence is prose and pictures, so its rows press + // nothing. + under := a.questionEvidenceRows(q.question, i, max(width-lead-1, 8), false) + under = append(under, a.questionNoteRows(questionOptionKeyAt(q.question, i), 0, max(width-lead-1, 8))...) + for _, line := range under { + out, at = append(out, indent+line), append(at, -1) + } + if len(under) > 0 { + out, at = append(out, ""), append(at, -1) + } + } + if a.questionRoomTakesOther() { + other := questionOtherAt(q.question) + for _, line := range a.questionPanelRow(q, itoa(other+1), "", questionPanelOtherWord, + "", "", pad, listRoom, room.focus == other, a.questionHovering(questionHoverPage, other)) { + out, at = append(out, questionPanelGap+line), append(at, other) + } + if room.focus == other { + out, at = append(out, indent+a.pal.dim(fit(questionPageOtherWord, max(width-lead, 1)))), append(at, -1) + } + } + prose(a.questionPageAttach(width)) + return out, at +} + +// ───────────────────────────────────────────────────────────────────────────── +// The geometry, on room.go's own terms. + +// questionRoomWindow is the page's body region. It is exactly `height` rows +// where the page has two panes, whose seam runs to the foot; one column hangs +// from the top with the slack below it, exactly as a short conversation does. +func (a *app) questionRoomWindow(width, height int) ([]row, int) { + if height <= 0 { + return nil, 0 + } + rows := a.questionRoomView(width, height) + return rows, max(height-len(rows), 0) +} + +// questionRoomRows is the body region at the frame's own height. +func (a *app) questionRoomRows(width int) []row { + return a.questionRoomView(width, a.viewHeight()) +} + +// questionRoomShowFocus KEEPS THE ANSWER THE POINTER IS ON IN VIEW, and it is the +// one place the list's window is moved for anything other than the wheel. +// +// An arrow that walks the pointer below the fold with nothing moving on screen +// is a key that reads as doing nothing (lane F measured it on the old page, where +// the window was moved only by the wheel). It scrolls the LEAST it can: a row +// already on screen does not move, and one off it is brought just inside the +// edge it left by — the reading position a person built is kept, not +// re-anchored under them. +// +// AND THE EVIDENCE PANE STARTS AT ITS TOP, because the pointer moving is the +// pane's content changing: an offset kept from the last answer's diagram would +// open the next answer's evidence halfway down. +func (a *app) questionRoomShowFocus() { + room := a.qroom + height := a.viewHeight() + if room == nil || height <= 0 { + return + } + room.detail, room.dirty = 0, true + lay := a.questionRoomLay(a.bodyWidth(), height) + first, last := -1, -1 + for i, of := range lay.listAt { + if of != room.focus { + continue + } + if first < 0 { + first = i + } + last = i + } + if first < 0 { + return + } + // In one column the answer's unfolded evidence is part of its reading: it + // is kept in view with its row where it fits, and the row wins where it + // does not. + if lay.seam < 0 { + for i := last + 1; i < len(lay.listAt) && lay.listAt[i] < 0 && i-first < lay.region; i++ { + if strings.TrimSpace(ansi.Strip(lay.list[i])) == "" && i+1 < len(lay.listAt) && lay.listAt[i+1] >= 0 { + break + } + last = i + } + } + offset := questionWindowStart(room.offset, len(lay.list), lay.region) + switch { + case first < offset: + offset = first + case last >= offset+lay.region: + offset = min(last-lay.region+1, first) + } + room.offset = questionWindowStart(offset, len(lay.list), lay.region) +} + +// questionRoomSpotAt is which answer a pointer at (x, y) reaches, and false +// where it reaches none. +// +// IT IS ASKED ABOUT THE COLUMN AS WELL AS THE ROW, because the page has two +// panes on one row: a press on the left of the seam is an answer's row, and a +// press on the right is the evidence beside it — prose and pictures, which have +// no gesture on this surface (app.go's [app.press]). A click aimed at a diagram +// that answered the question would be the worst press on the page. +func (a *app) questionRoomSpotAt(x, y int) (int, bool) { + room := a.qroom + top := a.bodyTop() + if room == nil || top < 0 { + return 0, false + } + a.questionRoomRows(a.bodyWidth()) + at := y - top + if at < 0 || at >= len(room.spots) { + return 0, false + } + spot := room.spots[at] + if spot.at < 0 || x < spot.from || x >= spot.to { + return 0, false + } + return spot.at, true +} + +// questionRoomPress resolves a click on the page and reports whether it took +// one. +// +// ONE PRESS MOVES ONTO AN ANSWER AND THE NEXT ONE TAKES IT, which is the only +// shape that can be both discoverable and safe. The first press puts the pointer +// on that answer — the same thing `↓` does, and the pane beside it shows that +// answer's evidence — and a press on the answer the pointer is already standing +// on is `enter`. A page where the first click answered would answer a question +// with evidence somebody had not read yet. +// +// A PRESS ANYWHERE ELSE FALLS THROUGH AND DOES NOTHING ([app.questionRoomSpotAt]). +func (a *app) questionRoomPress(x, y int) (tea.Cmd, bool) { + room := a.qroom + if room == nil { + return nil, false + } + // THE SETTLE GUARD IS THE POINTER'S TOO. A click that arrived less than + // [questionSettle] after the page was drawn was aimed at whatever was on + // screen before it, and a mouse is no better at stopping than a hand is. + if a.now().Sub(room.shown) < questionSettle { + return nil, true + } + at, ok := a.questionRoomSpotAt(x, y) + if !ok { + return nil, false + } + if at != room.focus { + room.focus, room.reading = at, false + a.questionRoomShowFocus() + a.questionRoomTouched() + return nil, true + } + a.questionRoomTouched() + return a.questionRoomEnter(), true +} + +// questionRoomScroll moves one of the page's two windows: the evidence pane +// where the pointer is over it, and the list (or the one column) everywhere +// else. +func (a *app) questionRoomScroll(x, delta int) { + room := a.qroom + if room == nil { + return + } + width, height := a.bodyWidth(), a.viewHeight() + lay := a.questionRoomLay(width, height) + if lay.seam >= 0 && x > lay.seam { + room.detail = questionWindowStart(room.detail+delta, len(lay.pane), lay.region) + } else { + room.offset = questionWindowStart(room.offset+delta, len(lay.list), lay.region) + } + a.questionRoomTouched() +} diff --git a/internal/tui3/questionpanel.go b/internal/tui3/questionpanel.go index d5d04de269..9a4cbd6835 100644 --- a/internal/tui3/questionpanel.go +++ b/internal/tui3/questionpanel.go @@ -81,7 +81,7 @@ func (a *app) questionPanelRows(q questionShown, width int) []string { } first := len(a.questionBands) title, headRows := a.questionPanelHead(q, width, inner) - rows := append(headRows, a.questionPanelBody(q, 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 != "" { @@ -140,6 +140,133 @@ func (a *app) questionPanelBeat(q questionShown, width, inner int) []string { return out } +// questionPanelEvidenced is the panel's body where its answers brought +// something to look at, and the ordinary body where they did not. +// +// THE CHOOSER HAS ALREADY DECIDED WHICH (questionchooser.go): at a width with +// room for two panes the answers stand in a list with the evidence of the one +// the pointer is on beside them ([viewSplit], preview pick A); narrower, the +// answer the pointer is on unfolds its evidence under its own row +// (preview-narrow pick B). Either way the evidence is given the rows that keep +// the panel inside [app.questionPanelTall], and a cut is said on its last row. +func (a *app) questionPanelEvidenced(q questionShown, width, inner, headRows int) []string { + if !questionAnswersCarryBlocks(q.question) { + return a.questionPanelBody(q, inner) + } + // The rows the panel spends that are not evidence: its two edges, the + // second tier of keys under it, and the head where it would not fit the edge. + spent := headRows + questionPanelFrameRows + if a.questionViewOf(q, width) == viewSplit { + return a.questionPanelSplit(q, inner, spent) + } + // MEASURED, NOT GUESSED: the body is laid once with no evidence under the + // pointer, which is exactly the rows the answers themselves take, and what + // is left of the panel's height is what the evidence may have. + first := len(a.questionBands) + bare := a.questionPanelBodyIn(q, inner, 0) + a.questionBands = a.questionBands[:first] + return a.questionPanelBodyIn(q, inner, max(a.questionPanelTall()-spent-len(bare), 1)) +} + +// questionPanelFrameRows is how many rows a panel draws around its body: the top +// edge, the bottom edge, and the one dim row of keys under the frame. +const questionPanelFrameRows = 3 + +// questionPanelTall is the most rows a panel may take above the box. +// +// HALF THE FRAME, AND THE REASON IS THE CONVERSATION. A question is about the +// work above it, so it never takes more of the screen than that work keeps: a +// panel taller than half the frame is a panel that has pushed the thing it is +// asking about off the screen. What does not fit is cut with its count and the +// way to the rest (`o open full`), where the page has the whole frame. +func (a *app) questionPanelTall() int { + _, height := a.size() + return height / 2 +} + +// questionPanelSplit is the body laid out as two panes: the answers in a list on +// the left, the evidence of the one the pointer is on at the right. +// +// │ │ │ +// │ ▸ 1 Stacked ◆ recommended │ Stacked │ +// │ 2 Marks only │ every name stays visible │ +// │ 3 Hidden behind a key │ │ +// │ 4 something else… │ then · costs three rows of transcript │ +// +// THE LIST CARRIES ONLY WHAT TELLS THE ANSWERS APART AT A GLANCE — the pointer, +// the key, the word and the pick's mark. What an answer leaves true moves into +// the pane, labelled, because the pane is where the pointer is already saying +// "this one"; drawn on the row as well, it would be said twice on one screen. +func (a *app) questionPanelSplit(q questionShown, inner, spent int) []string { + rows := make([]string, 0, len(q.question.Options)+8) + room := max(inner-2*len(questionPanelGap), 1) + rows = append(rows, a.questionPanelContext(q, room)...) + top := len(rows) + lead := questionPanelLead(q.question, room) + // The list is as wide as its widest row needs — the lead, the word, and the + // pick's mark with its word at the right — which is the page's own question + // and is answered in the page's own words ([app.questionListWant]). + want := 2*len(questionPanelGap) + a.questionListWant(q.question, lead) + if a.questionTakesOther(q) { + want = max(want, len(questionPanelGap)+lead+ansi.StringWidth(questionPanelOtherWord)+len(questionPanelGap)) + } + left, right := besideSplit(inner, want) + listRoom := max(left-2*len(questionPanelGap), 1) + pad := questionLabelPad(q.question.Options, listRoom) + list := []string{""} + band := func(at int) { + // A PRESS ON THE LIST PRESSES AN ANSWER, AND A PRESS ON THE EVIDENCE + // PRESSES NOTHING: the pane is prose and pictures, and a click aimed at + // a diagram that answered the question would be the worst press on this + // surface. The span is the frame's side and the list's own cells. + a.questionBands = append(a.questionBands, questionBand{ + row: top + len(list), span: hudSpan{from: 0, to: left + 1}, at: at, + }) + } + for i, option := range q.question.Options { + for _, line := range a.questionPanelOption(q, i, option, pad, listRoom, questionBeside, questionHoverPanel) { + band(i) + list = append(list, line) + } + } + if a.questionTakesOther(q) { + other := questionOtherAt(q.question) + for _, line := range a.questionPanelOther(q, pad, listRoom, questionHoverPanel) { + band(other) + list = append(list, line) + } + } + if row := a.questionScopeRow(q, listRoom); row != "" { + list = append(list, "", row) + } + list = append(list, "") + evidence := a.questionEvidenceRows(q.question, q.pick, max(right-2*len(questionPanelGap), 1), true) + fits := max(len(list)-1, a.questionPanelTall()-spent-len(rows)-1) + pane := []string{""} + for _, line := range a.questionCut(evidence, fits, a.questionOpenFullWord()) { + pane = append(pane, questionPanelGap+line) + } + for i := range list { + list[i] = questionPanelGap + list[i] + } + rows = append(rows, besides(a.pal, list, pane, left, inner)...) + // AND A SENTENCE ABOUT THE WHOLE QUESTION CROSSES THE SEAM. A clock that + // will answer says what a person can do about it (#954); in a column half + // the panel wide that sentence is cut mid-word, and in the pane beside the + // list it would read as the pointer's own answer. So it is drawn under both + // panes, at the frame's own width, which is the width it was written for. + if aside := a.questionClockAside(q, room); aside != "" { + rows = append(rows, "", questionPanelGap+aside) + } + return rows +} + +// questionOpenFullWord is the way to the rest of a cut, spelled from the key +// table so the offer and the key cannot drift apart. +func (a *app) questionOpenFullWord() string { + return questionKeySpelling(questionOpenKey) + " " + questionKeyWord(questionOpenKey) +} + // questionPanelBody is the panel WITHOUT its frame: the rows a person reads and // presses, laid out at `inner` cells. // @@ -150,7 +277,24 @@ func (a *app) questionPanelBeat(q questionShown, width, inner int) []string { // later` under that box would name two keys that do something else, which is the // one failure a key row exists to prevent. The pane draws this and names its own // keys (homeexchange.go's [exchangeHint]). +// AND IT DRAWS NO EVIDENCE UNDER THE POINTER. The split and the unfold are the +// panel's and the page's, where the drawing owns its own height and can say that +// it cut ([app.questionCut]); both callers here put these rows inside a CARD — +// the task record's ([app.taskRecordLandingRows], which then `fit`s every row to +// one line) and home's errand pane's ([app.homeExchangeRows], a card in a column +// of cards) — and a card that grew by every block an answer brought would push +// the rest of the column off the screen with no way to say so. If either surface +// should show an answer's case, that is a drawing decided for that surface with +// its own bound, not one inherited from this door. func (a *app) questionPanelBody(q questionShown, inner int) []string { + return a.questionPanelBodyIn(q, inner, 0) +} + +// questionPanelBodyIn is [app.questionPanelBody] with the rows the evidence +// under the pointer may take, where the answers brought any: zero draws none, +// and below zero is no bound at all, which only a drawing that owns its whole +// height may ask for. +func (a *app) questionPanelBodyIn(q questionShown, inner, evidence int) []string { room := max(inner-2*len(questionPanelGap), 1) rows := make([]string, 0, len(q.question.Options)+6) // THE FIRST ROWS ARE WHAT HAS TO BE READ BEFORE ANSWERING — the command a @@ -170,14 +314,10 @@ func (a *app) questionPanelBody(q questionShown, inner int) []string { // The widest answer word, which is the column every consequence beside it // starts in. It is bounded so one long label cannot push every consequence // off the panel. - pad := 0 - for _, option := range q.question.Options { - if w := ansi.StringWidth(strings.TrimSpace(option.Label)); w > pad && w <= room/2 { - pad = w - } - } + pad := questionLabelPad(q.question.Options, room) for i, option := range q.question.Options { - for _, line := range a.questionPanelOption(q, i, option, pad, room) { + under := questionUnder(evidence) + for _, line := range a.questionPanelOption(q, i, option, pad, room, under, questionHoverPanel) { // EVERY ROW AN ANSWER TAKES PRESSES THAT ANSWER, which is the sheet's // own bargain applied here: an answer whose words wrapped is not a // target that shrinks to its first line. @@ -189,7 +329,7 @@ func (a *app) questionPanelBody(q questionShown, inner int) []string { } if a.questionTakesOther(q) { other := questionOtherAt(q.question) - for _, line := range a.questionPanelOther(q, pad, room) { + for _, line := range a.questionPanelOther(q, pad, room, questionHoverPanel) { a.questionBands = append(a.questionBands, questionBand{ row: len(rows), span: hudSpan{from: 0, to: inner + 2}, at: other, }) @@ -386,9 +526,28 @@ func (a *app) questionPanelAside(q questionShown, width int) string { // `model`. const questionAsksWord = " asks" +// questionUnder is how much an answer's rows carry under the pointer, which is +// decided by where the question's evidence is drawn. +type questionUnder int + +const ( + // questionBeside is a list whose evidence stands in a pane beside it: the row + // is the answer's word and its marks, and nothing the pane already says. + questionBeside questionUnder = -2 + // questionUnbounded unfolds the evidence under the pointer with no bound, for + // a column that scrolls on its own. + questionUnbounded questionUnder = -1 + // questionRowOnly is the answer's row with what it leaves true beside its + // word and nothing under it: the page's one column, which unfolds the + // evidence under the pointer itself, with the person's own notes beside it. + questionRowOnly questionUnder = -3 +) + // questionPanelOption is one answer's rows: the row itself, and — only while the -// pointer is on it — what the asker said about it. -func (a *app) questionPanelOption(q questionShown, at int, option session.AnswerOption, pad, room int) []string { +// pointer is on it — what the asker said about it. `under` is how many rows of +// evidence may stand under the pointer on a question whose answers brought some, +// or one of the two shapes above. +func (a *app) questionPanelOption(q questionShown, at int, option session.AnswerOption, pad, room int, under questionUnder, hover questionHover) []string { key := questionOptionKeyAt(q.question, at) focused := at == q.pick picked := q.question.Pick != nil && strings.TrimSpace(q.question.Pick.Key) == key @@ -397,6 +556,14 @@ func (a *app) questionPanelOption(q questionShown, at int, option session.Answer word = key } say := strings.TrimSpace(option.Consequence) + if under == questionBeside || (focused && questionUnfolds(q.question, under)) { + // WHAT TAKING IT LEAVES TRUE MOVES INTO THE EVIDENCE, as its first + // labelled line (`then ·`), wherever the evidence is drawn for this + // answer — beside the list, or unfolded under this row (preview-narrow + // pick B). Said on the row as well, it would be said twice, and cut the + // first time. + say = "" + } // A CHECKLIST'S ROWS CARRY THEIR TICKS in a cell of their own between the // key and the word, so the digit that toggles a row is still on it and the // pointer still says where `space` lands — the old card let a tick stand in @@ -420,8 +587,8 @@ func (a *app) questionPanelOption(q questionShown, at int, option session.Answer // the surface having chosen. aside = a.pal.dim(questionSafeWord) } - rows := a.questionPanelRow(q, key, tick, word, say, aside, pad, room, focused, a.questionHovering(at)) - if !focused { + rows := a.questionPanelRow(q, key, tick, word, say, aside, pad, room, focused, a.questionHovering(hover, at)) + if !focused || under == questionBeside || under == questionRowOnly { return rows } // UNDER THE POINTER, AND ONLY THERE: what this answer means, and — where it @@ -430,7 +597,7 @@ func (a *app) questionPanelOption(q questionShown, at int, option session.Answer // and the pointer is the person saying which one they are weighing. lead := questionPanelLead(q.question, room) indent := questionPanelGap + strings.Repeat(" ", lead-len(questionPanelGap)) - for _, line := range a.questionPanelUnder(q, at, option, room-lead) { + for _, line := range a.questionPanelUnder(q, at, option, room-lead, under) { rows = append(rows, indent+line) } return rows @@ -439,10 +606,41 @@ func (a *app) questionPanelOption(q questionShown, at int, option session.Answer // questionHovering reports whether the mouse is over one answer's row. It asks // the pointer's own kind as well as the derived index, so a stale index cannot // light a row on a frame where the pointer is somewhere else entirely. -func (a *app) questionHovering(at int) bool { - return a.hot.kind == hoverChoices && a.hotAnswer == at +func (a *app) questionHovering(from questionHover, at int) bool { + switch from { + case questionHoverPage: + // THE PAGE'S ROWS ARE THE PANEL'S ROWS (questionpage.go), and the + // pointer over one of them is resolved by the page's own hit-test. + return a.hoveringQuestionOption(at) + case questionHoverPanel: + return a.hot.kind == hoverChoices && a.hotAnswer == at + } + return false } +// questionHover is which hit-test a drawing's rows answer to, passed in the way +// [questionUnder] is rather than sniffed from the app. +// +// A DRAWING KNOWS WHICH DRAWING IT IS AND THE APP DOES NOT. This asked +// `a.qroom != nil` — "is a page open anywhere" — and used the PAGE's hit-test +// whenever one was, so the same answer rows painted by the task record page +// ([app.taskRecordRows]) or the home errand pane ([app.homeExchangeRows]) +// through [app.questionPanelBody] lit the row the page's mouse was over, on a +// screen whose own pointer was somewhere else entirely. +type questionHover int + +const ( + // questionHoverPanel is the block above the box, and every other surface + // that paints these rows through [app.questionPanelBody]. + questionHoverPanel questionHover = iota + // questionHoverPage is the page `o` opens, which resolves a press through + // its own spots ([app.questionRoomSpotAt]). + questionHoverPage + // questionHoverNone is a drawing with no mouse of its own — a screenshot, + // or rows laid to be measured rather than painted. + questionHoverNone +) + // questionTickBlank is the cell an unticked checklist row stands in, so the // ticked and unticked rows keep one column. const questionTickBlank = " " @@ -463,6 +661,52 @@ func questionPanelLead(q session.Question, room int) int { // caps a question at four answers, eight on a checklist). const questionKeyCell = 1 +// questionLabelPad is the column every answer's word stands in: the widest label +// that still leaves half the row for what taking it produces. +// +// ONE ACCOUNT OF ONE NUMBER, because the panel's split, the panel's plain body, +// the page's list and the page's one column all draw the SAME list and were each +// computing this themselves — the split with `min(widest, room/2)` and the other +// three with `max(labels that fit)`. Those are different numbers, not two +// spellings of one: labels of 5 and 30 in a forty-cell list give 20 and 5. The +// three agree and the one was the outlier, so the three are the rule here, and +// it is the better rule — a label OVER the bound is not clipped by the pad (it +// simply overhangs into its own row, see [app.questionPanelRow]), so padding the +// short labels out to a bound no label reaches only spends the row on air. +func questionLabelPad(options []session.AnswerOption, room int) int { + pad := 0 + for _, option := range options { + if w := ansi.StringWidth(strings.TrimSpace(option.Label)); w > pad && w <= room/2 { + pad = w + } + } + return pad +} + +// questionListWant is how much width a list of answers asks for, gutters aside: +// the lead the pointer and key stand in, the widest answer's word, and — where +// the asker made a pick — its mark and word at the right edge. +// +// ONE FORMULA, because the panel's split and the page both ask this question of +// the same list and answered it differently: the panel counted the word +// `recommended` alone and counted it always; the page counted the MARK and the +// word, and only where there is a pick. The page's reading is the true one — the +// mark is drawn ([app.questionPanelOption]) and neither is drawn without a pick — +// so a question with no recommendation no longer reserves fourteen cells for one. +// Each caller adds its own gutters, which differ because the page indents its +// list one gap inside the seam and the panel does not. +func (a *app) questionListWant(q session.Question, lead int) int { + words := 0 + for _, option := range q.Options { + words = max(words, ansi.StringWidth(strings.TrimSpace(option.Label))) + } + want := lead + words + if q.Pick != nil { + want += 2 + ansi.StringWidth(a.icon(tokens.GRecommended)+" "+questionRecommendedWord) + 2 + } + return want +} + // questionPanelRow lays one answer out: the pointer, the key, the word in its // column, what taking it produces, and the aside at the right edge. // @@ -562,9 +806,34 @@ func (a *app) questionPanelRow(q questionShown, key, tick, word, say, aside stri return out } +// questionUnfolds reports whether the answer the pointer is on unfolds its whole +// evidence under its row: on the page's one column always, and on a panel +// whose answers brought something to look at. +func questionUnfolds(q session.Question, under questionUnder) bool { + return under == questionRowOnly || (under != questionBeside && questionAnswersCarryBlocks(q)) +} + // questionPanelUnder is what stands under the focused answer: its body in the // reading ink, and the pick's own case where this is the pick. -func (a *app) questionPanelUnder(q questionShown, at int, option session.AnswerOption, room int) []string { +// +// WHERE THE ANSWERS BROUGHT SOMETHING TO LOOK AT, IT IS THE EVIDENCE, UNFOLDED +// (preview-narrow pick B): the same account the pane beside a wide panel draws +// ([app.questionEvidenceRows]), without the word the row above already says, +// cut to the rows the panel has left. A panel whose answers are words alone keeps rec A's fold — the +// body and one line of the pick's case — because a labelled column under a row +// of words is a card pretending to be a page. +func (a *app) questionPanelUnder(q questionShown, at int, option session.AnswerOption, room int, under questionUnder) []string { + if questionAnswersCarryBlocks(q.question) { + if under == 0 { + // The measuring pass ([app.questionPanelEvidenced]): the answers alone. + return nil + } + rows := a.questionEvidenceRows(q.question, at, max(room, 8), false) + if under == questionUnbounded { + return rows + } + return a.questionCut(rows, int(under), a.questionOpenFullWord()) + } out := make([]string, 0, 3) if body := strings.TrimSpace(option.Body); body != "" { for i, line := range wrap(body, max(room, 8)) { @@ -590,41 +859,12 @@ const questionPanelSayFloor = 12 // the rest. const questionPanelBodyRows = 2 -// questionPickCase is the asker's case for its pick, on one line: why it would -// take this, how sure it is, and what would change its mind. -// -// THE EMPTINESS LAW. A pick with no reason, no confidence and nothing that would -// change its mind draws no line at all — never an empty one, and never the word -// "unknown". -func questionPickCase(q session.Question, key string) string { - if q.Pick == nil || strings.TrimSpace(q.Pick.Key) != key { - return "" - } - parts := make([]string, 0, 3) - if reason := strings.TrimSpace(q.Pick.Reason); reason != "" { - parts = append(parts, reason) - } - if sure := questionConfidenceWord(q.Pick.Confidence); sure != "" { - parts = append(parts, sure) - } - if would := questionAfterIf(q.Pick.WouldChange); would != "" { - // THROUGH THE ROOM'S OWN JOINER ([questionAfterIf]), because this row is - // the same clause said shorter. Asked what would change its mind a model - // answers "If the index has to be read from another machine", and the row - // drew `would switch if if the index has…` — the defect the room already - // fixed on 2026-09-10, arriving here the day the panel started drawing - // the same sentence. - parts = append(parts, questionWouldSwitchWord+would) - } - return strings.Join(parts, " · ") -} - // questionPanelOther is the last row: the answer that is not on the list, and // the box it becomes while the pointer is on it. -func (a *app) questionPanelOther(q questionShown, pad, room int) []string { +func (a *app) questionPanelOther(q questionShown, pad, room int, hover questionHover) []string { at := questionOtherAt(q.question) if q.pick != at { - return a.questionPanelRow(q, itoa(at+1), "", questionPanelOtherWord, "", "", pad, room, false, a.questionHovering(at)) + return a.questionPanelRow(q, itoa(at+1), "", questionPanelOtherWord, "", "", pad, room, false, a.questionHovering(hover, at)) } // THE ROW IS THE BOX. There is no mode to enter and nothing hidden behind a // letter: the pointer arriving here is what opens it, `enter` sends what is diff --git a/internal/tui3/questionroom.go b/internal/tui3/questionroom.go index 851faa2bcb..5288a031de 100644 --- a/internal/tui3/questionroom.go +++ b/internal/tui3/questionroom.go @@ -46,95 +46,42 @@ import ( // while the box is EMPTY, exactly as `x` (stop) is taken on this surface today, // so a person mid-sentence never loses a keystroke to a question. // -// ── THE ROWS, DRAWN HERE SO THEY STAY TRUE ── +// ── THE PAGE IS DRAWN IN questionpage.go ── // -// The body region, at 92 columns, a choice with three answers and the first one -// open. `?` is [tokens.GNeedsHuman] and wears the question's amber; an answer's -// KEY is the payload hue and its LABEL is bold ink — the hue was on the label -// until the owner retired it (colour pick C, 2026-09-11: COLOUR IS STROKE AND -// NEVER FILL), and the weight carries what the colour was carrying. An answer's -// body is ink; every attribution, consequence, reason and confidence is dim. +// This file is the page's STATE and its KEYS: what the reader is standing on, +// what the foot has composed, and what each key does. How it is laid out — the +// two panes at a hundred columns and wider, the one column narrower, the two +// windows and what a press reaches — is questionpage.go's, drawn with the +// evidence renderer every question drawing shares (questionevidence.go). // -// ── THE HIERARCHY, WHICH IS THE POINT OF THE SHAPE ── +// ── THE POINTER, AND THE WAYS TO MOVE IT ── // -// Three tiers and every row is one of them: the heading you scan for, the -// sentence you read, the asides you weigh. Each aside says which aside it is — -// `then ·`, `why this one ·`, `would switch if` — because they are all dim and -// all one sentence long, and a page that drew them bare gave a person four -// identical grey rows to tell apart by reading them in full (the owner, -// 2026-09-10: "the same line and next line in options look same and a bit -// weird"). One blank row closes an open answer, so the next heading is not -// merely a different indent. -// -// ‹ back question · 1 of 1 -// ? which store should the ledger sit on? -// the model · a schema change is next and it is cheaper before rows exist -// the turn waits on it · task 12 goes on without it -// -// what it showed you -// where the rows are today -// app ──▶ sqlite -// -// ▾ 1 postgres my pick · fairly sure -// rows already carry a foreign key into it, and the migration is one file -// then · the ledger and the rest of the project share one connection -// why this one · it is the only store the reporting job already reads -// would switch if the ledger ever has to run without a server -// -// what it would look like -// app ──▶ pg ──▶ report -// › keep the sqlite file as the source of truth -// ? does the reporting job read it directly -// ↳ it does, over the same connection string -// -// ▸ 2 sqlite -// ▸ 3 a file per day -// -// And the foot, pinned above the box exactly where every other question on this -// surface sits (view.go's chrome stack): -// -// answering 1 postgres · with keep the sqlite file · once -// [enter] take it · [esc] later · [x] compare · [?] ask back · [↑↓] choose -// -// ── THE POINTER, AND THE THREE WAYS TO MOVE IT ── -// -// `↑`/`↓` walk the answers; `→` opens the one under the pointer and `←` folds -// it; `enter` takes it, which is the block's own law arriving on the page -// (#789). A digit still answers at once, from anywhere. And a click on an -// answer's row moves the pointer onto it and opens it, with a second click on -// the same row being `enter` — one press to read, one to decide -// ([app.questionRoomPress] says why it is two). +// `↑`/`↓` walk the answers and the evidence beside them follows; `enter` takes +// the answer the pointer is on, which is the block's own law arriving on the +// page (#789); a digit moves the pointer straight to its answer. Where there are +// two panes, `→` hands the arrows to the evidence so a long diagram can be read +// to its end and `←` hands them back. And a click on an answer's row moves the +// pointer onto it, with a second click on the same row being `enter` — one press +// to read, one to decide ([app.questionRoomPress] says why it is two). // // ── ALIGNMENT, STATED ONCE ── // // DESIGN.md: "Head at the gutter; option rows indented one key-cell; answers row -// indented like the options". So the head and the back crumb start at column 0, -// everything under them is indented [questionIndent], and an option's own body -// is indented one further key-cell so the word and the body cannot be misread as -// two answers. The three widths below are the whole of the page's geometry and -// nothing computes its own. +// indented like the options". So everything on the page is indented +// [questionIndent], the answers' rows are the panel's own, and a shape's parts +// are indented one further key-cell ([questionBodyIndent]) so a part and what is +// written under it cannot be misread as two answers. const ( // questionIndent is the one key-cell every row under the head is indented by. questionIndent = " " - // questionBodyIndent is an option's body, one key-cell further in: the digit, - // the space and the fold mark are what it clears. + // questionBodyIndent is what is written under one part of a shape — a note + // on a checklist row, the exchange under a pair — one key-cell further in + // than the part: the key, its space and its mark are what it clears. questionBodyIndent = " " - // questionAttachIndent is where the question's OWN evidence sits: under its - // one dim title, and one step in from it, so a diagram never starts at the - // gutter while every sentence beside it is indented. - questionAttachIndent = " " - // questionRoomFloor is the narrowest page that still draws sections. Below it - // there is no room for a fold mark, a digit and a word, and the page draws - // the linear shape every form owes the reader tier. - questionRoomFloor = 34 // questionCompareFloor is where the compare table stops being a table. // DESIGN.md: "compare stacks under 80 cols". questionCompareFloor = 80 - // questionLayoutFloor is where a two-pane layout block stops being two panes. - // DESIGN.md: "two pre-formatted panes side by side above 100 cols, stacked - // below". - questionLayoutFloor = 100 ) // The words this page says, spelled once. Every one of them is in the person's @@ -146,20 +93,12 @@ const ( // differently from the status line beside it would read as a different // program. questionSep = " · " - // questionBackWord is the crumb out. It names `esc` because a page a person - // walked into has to say how they leave it, which is room.go's own rule. - questionBackWord = "back" - // questionPickWord marks the asker's own pick. It is the asker saying which - // one it would take, never a default that happens on its own — a question - // with a clock says so in its own row (S1's card), and this page never - // pretends the pick is one. - questionPickWord = "my pick" // questionWouldSwitchWord opens the line that is the most useful thing on the // page: what would change the asker's mind. A person who disagrees with a // pick usually disagrees with exactly this. questionWouldSwitchWord = "would switch if " - // questionThenWord and questionWhyWord label the two dim lines under an - // open answer, and they exist because four grey sentences stacked in one + // questionThenWord and questionWhyWord label two of the dim lines of an + // answer's case (questionevidence.go), and they exist because four grey sentences stacked in one // column read as one grey paragraph nobody can tell apart — the owner, // 2026-09-10, on a page exactly like that: "make sure there is some // textual hierarchy in design in options like the same line and next line @@ -300,16 +239,17 @@ type questionRoom struct { // act on. It is an index into the options rather than a key so an empty // option list is simply focus 0 on nothing. focus int - // open says which sections are expanded. A room OPENS ON ITS PICK — the one - // answer a person most wants to read is the one the asker would take — and - // everything else starts folded, which is what makes the page a page rather - // than a wall. - open map[int]bool - // compare swaps the sections for the table `x` draws. + // compare swaps the answers for the table `x` draws. compare bool - // picked is what the foot would send. It is a slice because a checklist and a - // run of pairs both answer with several keys, and [session.Answer.Picked] is - // the field that carries them. + // reading is the arrows handed to the evidence pane (`→`): `↑`/`↓` scroll it + // rather than walking the answers, until `←` hands them back or the pointer + // moves by any other road. + reading bool + // picked is what a CHECKLIST's foot would send: the rows ticked, in the order + // they were ticked. A list of answers has no second state to hold — `enter` + // takes the answer the pointer is on ([app.questionRoomSends]) — so a page + // whose choice and pointer could disagree is a page that can send an answer + // nobody is looking at. picked []string // scope is how long the answer lasts, and it is only ever one the question // offered ([session.Question.Scope]). @@ -343,25 +283,26 @@ type questionRoom struct { // drawn where the foot was, because a refusal a person cannot see is an // answer that silently did nothing. refused string - // The reader's own position, on room.go's terms: it is HERE so that leaving - // restores the conversation without having moved it. + // The reader's own positions, on room.go's terms: they are HERE so that + // leaving restores the conversation without having moved it. offset is the + // list's window (or the one column's), and detail is the evidence pane's. offset int - // The row cache, rebuilt on content or width and at no other time. + detail int + // The row cache, rebuilt on content or size and at no other time. rows []row - // spots is which ANSWER each cached row belongs to, or -1 for a row that - // belongs to none — the whole of how a press and a hover find an option on - // this page. + // spots is what a press on each cached row reaches (questionpage.go's + // [questionSpot]). // // IT IS WRITTEN BY THE DRAWING AND READ BY THE POINTER, which is the bargain // every hit-test on this surface keeps (hover.go's law): what lights is // exactly what a press acts on, because both of them are reading the row the - // layout actually wrote. Only an answer's OWN row is a target — its body is - // prose, a diagram and the person's own notes, and a click that answered a - // question because somebody aimed at a diagram would be the worst press on - // this surface. - spots []int - width int - dirty bool + // layout actually wrote. + spots []questionSpot + // seam is the column the two panes meet at on the cached rows, or -1. + seam int + height int + width int + dirty bool } // questionRoomOpen reports whether a question page is up. It is the one reading @@ -369,6 +310,11 @@ type questionRoom struct { // [app.roomOpen] is for a node's page. func (a *app) questionRoomOpen() bool { return a.qroom != nil } +// questionPageShows reports whether the page is up over this very question. +func (a *app) questionPageShows(q questionShown) bool { + return a.qroom != nil && a.qroom.head.token() == q.token() +} + // raiseQuestionRoom puts the page over the conversation. // // IT IS THE DOOR THE OTHER FORMS PROMOTE THROUGH (DESIGN.md: "Every form folds @@ -380,31 +326,30 @@ func (a *app) raiseQuestionRoom(head questionShown) { q := head.question room := &questionRoom{ head: head, - open: map[int]bool{}, comments: map[string]string{}, asks: map[string]*questionAsk{}, scope: questionDefaultScope(q), shown: a.now(), dirty: true, - } - // THE PAGE OPENS ON THE PICK. Every other section starts folded, so what a - // person meets is the asker's own answer with its reason and its evidence - // under it, and the alternatives one key away — which is the order they will - // read them in anyway. - // - // AND WHERE THERE IS NO PICK THE POINTER STARTS WHERE THE BLOCK'S DOES - // ([questionPointerStart]): on the answer that loses nothing, wherever the - // question marks one. `enter` takes the answer the pointer is on, so the two - // forms may not disagree about which answer that is — a page whose pointer - // began one row above the block's would make one key mean two things - // depending on which size the question happened to be drawn at. - room.focus = questionPointerStart(q) - if q.Pick != nil && room.focus < len(q.Options) && - strings.TrimSpace(q.Options[room.focus].Key) == strings.TrimSpace(q.Pick.Key) { - room.open[room.focus] = true - } + // NO SEAM UNTIL THE BODY HAS DRAWN ONE. The foot's rule reads this rather + // than working the split out a second time, so before the first lay it + // must say "one column" — a junction is a claim about where the body + // split, and on the frame a page opens in the body has not answered yet. + seam: -1, + } + // THE PAGE OPENS WHERE THE BLOCK'S POINTER STOOD. A person who walked to the + // second answer and pressed `o` to read it opened the page ABOUT the second + // answer, and the block's pointer itself started where the law puts it + // ([questionPointerStart]: the asker's pick, or the answer that loses nothing + // where nobody but a person may answer). `enter` takes the answer the + // pointer is on, so the two sizes of one question may not disagree about + // which answer that is. + room.focus = head.pick room.input = newQuestionInput(q) a.qroom = room + if walk := a.questionRoomWalk(); room.focus < 0 || room.focus >= walk { + room.focus = questionPointerStart(q) + } a.touch() } @@ -471,123 +416,7 @@ func (a *app) questionRoomTouched() { } // ───────────────────────────────────────────────────────────────────────────── -// The page. - -// questionRoomRows is the whole body region: the crumb, the head, the -// attribution, and then either the option sections or the compare table. -func (a *app) questionRoomRows(width int) []row { - room := a.qroom - if room == nil || width <= 0 { - return nil - } - if a.questionDrainReplies() { - room.dirty = true - } - if !room.dirty && room.width == width && room.rows != nil { - return room.rows - } - out := make([]row, 0, 32) - spots := make([]int, 0, 32) - add := func(text string) { - out = append(out, row{text: text, entry: -1}) - spots = append(spots, -1) - } - - add(a.questionCrumb(width)) - for _, line := range a.questionHeadRows(width) { - add(line) - } - add("") - // THE QUESTION'S OWN EVIDENCE COMES BEFORE THE ANSWERS AND NOT AFTER THEM. - // It used to be drawn last, untitled, at the head's indent — so a page whose - // asker had drawn a picture per answer ended in a run of unowned diagrams - // that read as a second page begun halfway down the first (the owner, - // 2026-09-10: "the layout org and everything seems really bad"). It is - // evidence about the WHOLE decision, which is the thing a person weighs - // BEFORE they weigh one answer against another, so it is said first, once, - // under a title that says what it is. - for _, line := range a.questionAttachRows(width) { - add(line) - } - switch { - case room.compare: - for _, line := range a.questionCompareRows(width) { - add(line) - } - case room.input.kind != session.InputNone: - // A STRUCTURED INPUT REPLACES THE OPTION SECTIONS AND NEVER SITS UNDER - // THEM. DESIGN.md's ladder puts structured input one rung BELOW the - // question — blanks, a checklist, this-or-this, a dial are the answer - // itself, not a garnish on a list of answers — so a question that carries - // one is drawn as that shape and the options, where it has any, are the - // shape's own rows. - for _, line := range a.questionInputRows(width) { - add(line) - } - default: - lines, owners := a.questionOptionRows(width) - for i, line := range lines { - add(line) - spots[len(spots)-1] = owners[i] - } - } - room.rows, room.spots, room.width, room.dirty = out, spots, width, false - return out -} - -// questionCrumb is the way out and where you are, on one row: the crumb at the -// gutter and the question's place in what is open on the right. -func (a *app) questionCrumb(width int) string { - back := a.pal.dim(a.icon(tokens.GScopeUp) + " " + questionBackWord) - if width < questionRoomFloor { - return fit(back, width) - } - return back -} - -// questionHeadRows are the head and the attribution under it, and they are the -// contract DESIGN.md writes for this page: "asked by · why now · what is paused -// on it · what goes on without it". -// -// ALL FOUR OR AS MANY AS ARE TRUE. The emptiness law governs every one of them — -// a question with no reason draws no reason line, and one that nothing is -// waiting on says so rather than leaving a person to guess whether the silence -// means everything or nothing. -func (a *app) questionHeadRows(width int) []string { - room := a.qroom - if room == nil { - return nil - } - out := make([]string, 0, 4) - mark := a.pal.warnBold(a.icon(tokens.GNeedsHuman)) - head := strings.TrimSpace(room.head.question.Head) - lines := wrap(head, max(1, width-2)) - for i, line := range lines { - if i == 0 { - out = append(out, mark+" "+a.pal.ink(line)) - continue - } - out = append(out, questionIndent+a.pal.ink(line)) - } - // The asker and the reason share a row: they are one sentence — who is asking - // and why now — and two rows for it would push the answers off a short page. - attribution := questionAskerWord(room.head.question.Asker) - if reason := strings.TrimSpace(room.head.question.Reason); reason != "" { - if attribution != "" { - attribution += questionSep - } - attribution += reason - } - if attribution != "" { - for _, line := range wrap(attribution, max(1, width-len(questionIndent))) { - out = append(out, questionIndent+a.pal.dim(line)) - } - } - if blocking := questionBlockingWord(room.head.question.Blocking); blocking != "" { - out = append(out, questionIndent+a.pal.dim(fit(blocking, max(1, width-len(questionIndent))))) - } - return out -} +// What the page says about the question, in words other drawings share. // questionBlockingWord is the third attribution row: what is paused on this // question and what carries on regardless. @@ -623,83 +452,6 @@ func questionTasksWord(ids []string) string { return strconv.Itoa(len(ids)) + " tasks" } -// questionOptionRows draws the answers as sections. -// -// ONE ROW PER ANSWER WHEN FOLDED, and the whole of it when open: the fold mark, -// the digit, the word, and — on the asker's own pick — `my pick` with its -// confidence, dim, on the right. Opening one adds its body, its consequence -// lines, its blocks, and, on the pick, the reason and what would change its mind. -func (a *app) questionOptionRows(width int) ([]string, []int) { - room := a.qroom - if room == nil || len(room.head.question.Options) == 0 { - return nil, nil - } - out := make([]string, 0, len(room.head.question.Options)*4) - owners := make([]int, 0, len(room.head.question.Options)*4) - for i, opt := range room.head.question.Options { - out = append(out, a.questionOptionHead(i, opt, width)) - owners = append(owners, i) - if !room.open[i] { - continue - } - body := a.questionOptionBody(i, opt, width) - out = append(out, body...) - for range body { - owners = append(owners, -1) - } - } - return out, owners -} - -// questionOptionHead is the one row an answer always has. -func (a *app) questionOptionHead(i int, opt session.AnswerOption, width int) string { - room := a.qroom - mark := tokens.GCollapsed - if room.open[i] { - mark = tokens.GExpanded - } - lead := questionIndent + a.pal.dim(a.icon(mark)) + " " - key := opt.Key - if key == "" { - key = strconv.Itoa(i + 1) - } - // THE LABEL IS THIS SECTION'S HEADING AND IS DRAWN AS ONE: the key steps up - // to the payload hue and the word is ink with the weight on it, which is - // exactly how the panel above the box spells an answer. It was the body's - // own ink, so a heading and the paragraph under it were one column of - // identical grey and the page had no hierarchy at all (the owner, - // 2026-09-10); it was then the question hue, which is the amber the marks - // keep (owner ruling 2026-09-11, colour pick C). - row := lead + a.pal.data(key) + " " + a.pal.bold(a.pal.ink(strings.TrimSpace(opt.Label))) - // The pick's badge is right-aligned, dim, and cut before the word is: a page - // too narrow to say `my pick · fairly sure` still has to say which answers - // there are. - if room.head.question.Pick != nil && room.head.question.Pick.Key == opt.Key { - badge := questionPickWord - if c := questionConfidenceWord(room.head.question.Pick.Confidence); c != "" { - badge += questionSep + c - } - row = questionRightAlign(row, a.pal.dim(badge), width) - } - // WHAT THE FOOT WOULD SEND WEARS THE GROUND LADDER'S *SELECTED* STEP AND - // THE ANSWER THE POINTER IS MERELY STANDING ON WEARS ITS CURSOR STEP. That - // is the ladder's own distinction and it is the reason the two are not one - // band: `selected` is "this is the chosen thing" and `cursor` is "the keys - // land here", and a page that painted them alike would answer "which one - // did I choose" with the row somebody happened to have walked to. - // - // AND THE POINTER LIGHTS THE SAME ROW A PRESS ACTS ON, which is hover.go's - // law: this row is the target, its body is not, and nothing else on the - // page brightens under a pointer because nothing else on it answers. - switch { - case a.questionTaken(opt.Key): - row = a.pal.selected(fit(row, width), width) - case i == room.focus, a.hoveringQuestionOption(i): - row = a.pal.cursor(fit(row, width), width) - } - return fit(row, width) -} - // questionConfidenceWord is how sure the asker is, in the asker's own three // words. It is dim beside the pick and never a number: a percentage on a // judgement is a machine pretending to have measured something. @@ -715,69 +467,6 @@ func questionConfidenceWord(c session.Confidence) string { return "" } -// questionOptionBody is everything under an open answer, in the order a person -// reads it: what it means, what it costs, what it would look like, and — where -// this is the pick — why the asker would take it and what would change its mind. -func (a *app) questionOptionBody(i int, opt session.AnswerOption, width int) []string { - room := a.qroom - inner := max(1, width-len(questionBodyIndent)) - out := make([]string, 0, 8) - // AN ANSWER WITH NO BODY DRAWS NO ROW FOR ONE. [wrap] answers an empty string - // with a single empty line — which is right for a paragraph and wrong here, - // where it would be a blank row claiming the asker said something. - if body := strings.TrimSpace(opt.Body); body != "" { - for _, line := range wrap(body, inner) { - out = append(out, questionBodyIndent+a.pal.ink(line)) - } - } - // EVERY LINE UNDER THE BODY SAYS WHICH LINE IT IS. The consequence, the - // asker's reason and what would change its mind are all dim and all one - // sentence long, so on a page that drew them bare they were three - // indistinguishable grey rows a person had to read in full to find out - // which was which. Each carries its own word now, and the words are the - // ones somebody would use out loud. - if c := strings.TrimSpace(opt.Consequence); c != "" { - out = append(out, a.questionAsideRows(questionThenWord+c, inner)...) - } - if room.head.question.Pick != nil && room.head.question.Pick.Key == opt.Key { - if why := strings.TrimSpace(room.head.question.Pick.Reason); why != "" { - out = append(out, a.questionAsideRows(questionWhyWord+why, inner)...) - } - if change := strings.TrimSpace(room.head.question.Pick.WouldChange); change != "" { - out = append(out, a.questionAsideRows(questionWouldSwitchWord+questionAfterIf(change), inner)...) - } - } - // THE EVIDENCE SITS UNDER THE ANSWER IT BELONGS TO, at the body's own - // indent, with a blank row before each so two diagrams can never run into - // one another. - for _, block := range opt.Blocks { - out = append(out, "") - out = append(out, a.questionBlockRows(block, len(questionBodyIndent), width)...) - } - out = append(out, a.questionNoteRows(opt.Key, len(questionBodyIndent), width)...) - // AND ONE BLANK ROW CLOSES AN OPEN ANSWER, so the next answer's heading is - // separated from the paragraph above it by something other than an indent. - // It is the cheapest half of the hierarchy and the one a person reads - // without noticing they are reading it. - out = append(out, "") - _ = i - return out -} - -// questionAsideRows is one labelled dim line under an answer — the consequence, -// the asker's reason, what would change its mind. -// -// THEY ARE ALL DIM AND THAT IS THE LAW (DESIGN.md's hue rule: "the pick's -// reason and every attribution are dim; option bodies are ink"), so the label -// and not the ink is what tells one from the next. -func (a *app) questionAsideRows(text string, inner int) []string { - out := make([]string, 0, 2) - for _, line := range wrap(text, inner) { - out = append(out, questionBodyIndent+a.pal.dim(line)) - } - return out -} - // questionAfterIf makes the asker's own sentence read as the tail of `would // switch if `. // @@ -859,119 +548,89 @@ func (a *app) questionNoteRows(part string, indent, width int) []string { return out } -// questionAttachRows are the blocks the QUESTION carries, as opposed to the -// ones hanging off one answer: evidence about the whole decision. -// -// THEY ARE ONE TITLED SECTION AND NOT A TAIL. They used to be drawn last, with -// no heading, at the head's own indent — so a page whose asker had drawn a -// picture per answer ended in a run of diagrams belonging to nobody, starting -// at the gutter while every sentence above them was indented. Under a title -// that says what they are, at an indent of their own, they read as the one -// thing they are ([questionRoomRows] says why they are drawn first). -func (a *app) questionAttachRows(width int) []string { - room := a.qroom - if room == nil || len(room.head.question.Attach) == 0 || room.compare { - return nil - } - title := fit(questionAttachWord, max(1, width-len(questionIndent))) - out := []string{questionIndent + a.pal.dim(title)} - for i, block := range room.head.question.Attach { - if i > 0 { - out = append(out, "") - } - out = append(out, a.questionBlockRows(block, len(questionAttachIndent), width)...) - } - return append(out, "") -} - -// questionTaken reports whether the foot would send this key. -func (a *app) questionTaken(key string) bool { - room := a.qroom - if room == nil || key == "" { - return false - } - for _, k := range room.picked { - if k == key { - return true - } - } - return false -} - -// questionRightAlign puts a badge at the right edge of a row, or drops it where -// the row has no room. It is the one place this page measures, and it measures -// the PAINTED strings, because that is what the terminal draws. -func questionRightAlign(left, right string, width int) string { - gap := width - ansi.StringWidth(left) - ansi.StringWidth(right) - if gap < 2 { - return left - } - return left + strings.Repeat(" ", gap) + right -} - // ───────────────────────────────────────────────────────────────────────────── // The foot: what the answer would be, and the keys. -// questionFootHeight is how many rows the pinned foot takes: two while the -// question is open, one while it is reporting a refusal, none otherwise. +// questionFootHeight is how many rows the pinned foot takes: two while the page +// is open and none otherwise, whatever the foot is saying. // // THERE IS NO ANSWERED HEIGHT, because an answered question has no page: the // page folds on the answer and the receipt is the block's ([app.questionAnswer] -// says why). +// says why). AND NO SHORTER ONE: a foot that was one row while it reported a +// refusal moved the whole page up a row under the person reading it. func (a *app) questionFootHeight() int { if a.qroom == nil { return 0 } - if a.qroom.refused != "" { - return 1 - } return 2 } // questionFootRows is the foot, pinned above the box exactly where every other // question on this surface sits (view.go's chrome stack). // -// TWO ROWS AND NEVER MORE. The first says what enter would send — the pick, what -// was typed beside it, how many parts were commented on, and the scope — and the -// second is the keys. A foot that grew with the question would push the box off -// a short screen, which is the one thing a page that promises never to take the -// keyboard cannot do. +// ────────────────────────────────────────┴─── answering 1 SQLite · 1 noted ──── +// ↑↓ choose · enter take it · esc later · → detail c change · x compare +// +// TWO ROWS AND NEVER MORE. The first is the rule that closes the page — meeting +// the seam where there are two panes — with what `enter` would send written into +// it the way the frame writes words into an edge; the second is the keys, the +// ones that answer at the left and the ones that say something about the decision +// at the right, which is the panel's two tiers on one row (hints pick A). While +// the box is pointed at a part, or `d` is waiting for its second press, or the +// engine refused, the rule says that instead and the row under it says how to +// leave it. A foot that grew with the question would push the box off a short +// screen, which is the one thing a page that promises never to take the keyboard +// cannot do. func (a *app) questionFootRows(width int) []string { room := a.qroom if room == nil || width <= 0 { return nil } - if room.refused != "" { - return []string{a.pal.warn(fit(room.refused, width))} - } - if room.deciding { - return []string{ - a.pal.ink(fit(a.questionDecideLine(), width)), - a.pal.dim(fit(questionDecideAgain, width)), - } + // THE RULE THAT CLOSES THE PAGE IS DRAWN IN THE PAGE'S OWN FRAME, NOT THE + // TERMINAL'S. The body is charged for the task column ([app.bodyWidth]) and + // the foot is chrome drawn at the full frame width, so a rule spanning + // `width` ran on under the rail while the page's top rule stopped at the + // body's edge — two edges of one object ending in different columns. With + // the column up on a 140-cell terminal the body is still wide enough to + // split, which is exactly when it showed. + rule := func(words string) string { + return besideRule(a.pal, a.bodyWidth(), a.questionRoomSeam(), tokens.GFrameTeeUp, words) } - if room.decidingKind { - return []string{ - a.pal.ink(fit(a.questionDecideKindLine(), width)), - a.pal.dim(fit(questionDecideKindAgain, width)), - } + switch { + case room.refused != "": + return []string{rule(a.pal.warn(room.refused)), a.questionRoomOfferRow(width)} + case room.deciding: + return []string{rule(a.pal.ink(a.questionDecideLine())), " " + a.pal.dim(fit(questionDecideAgain, width-2))} + case room.decidingKind: + return []string{rule(a.pal.ink(a.questionDecideKindLine())), " " + a.pal.dim(fit(questionDecideKindAgain, width-2))} } if prompt := a.questionPromptWord(); prompt != "" { - return []string{ - a.pal.ink(fit(prompt, width)), - a.pal.dim(fit(a.questionSaying(), width)), - } + return []string{rule(a.pal.ink(prompt)), " " + a.pal.dim(fit(a.questionSaying(), width-2))} } - return []string{ - a.questionComposeRow(width), - a.questionRoomOfferRow(width), + return []string{rule(a.questionComposeWords()), a.questionRoomOfferRow(width)} +} + +// questionRoomSeam is where the page's two panes meet, or -1 on a page of one +// column. +// +// IT IS WHAT THE BODY ACTUALLY DREW, read back rather than worked out again. +// [app.questionRoomView] stores the seam of the layout it painted, and the body +// is laid before the chrome that carries this foot ([app.chatFrameLines] calls +// bodyRows and then chrome), so the junction the foot draws cannot be a column +// the body did not split at. Recomputing it here was the same arithmetic in a +// second place, and the moment [app.questionPageListWant] changed, the two edges +// of one frame would have met the seam in different columns. +func (a *app) questionRoomSeam() int { + if a.qroom == nil { + return -1 } + return a.qroom.seam } -// questionPromptWord is the sentence that replaces the compose row while the box -// is pointed at a part rather than at the answer: `c`, `?` and `n` each say what -// the box is writing now, because a box whose meaning changed silently is a box -// that puts a comment into the answer. +// questionPromptWord is the sentence that replaces the compose words while the +// box is pointed at a part rather than at the answer: `c`, `?` and `n` each say +// what the box is writing now, because a box whose meaning changed silently is a +// box that puts a comment into the answer. func (a *app) questionPromptWord() string { room := a.qroom switch { @@ -985,52 +644,72 @@ func (a *app) questionPromptWord() string { return "" } -// questionSaying is the dim second row under a prompt: what the box holds so -// far, or the way out of it. +// questionSaying is the dim second row under a prompt: the way out of it. func (a *app) questionSaying() string { return "esc " + questionKeyWord(questionLaterKey) } -// questionComposeRow is the first foot row: `pick · with · notes · scope`. -func (a *app) questionComposeRow(width int) string { +// questionRoomSends is what `enter` would send from the page right now: the +// answers and the words. +// +// IT IS ONE READING, asked by `enter` ([app.questionRoomEnter]) and by the foot +// that says what `enter` would send ([app.questionComposeWords]), so the two +// cannot disagree. On a list of answers it is the answer the pointer is on, with +// whatever is in the box as the words that go with it; on `something else…` it +// is the words alone; on a checklist it is the rows ticked. +func (a *app) questionRoomSends() ([]string, string) { + room := a.qroom + words := strings.TrimSpace(a.input.String()) + if room.input.kind != session.InputNone { + return append([]string{}, room.picked...), words + } + if key := questionOptionKeyAt(room.head.question, room.focus); key != "" { + return []string{key}, words + } + return nil, words +} + +// questionComposeWords is what the foot's rule says `enter` would send: +// `answering 1 postgres · with keep the sqlite file · 1 noted · just this once`. +// It is ink where there is something to send and dim where there is not. +func (a *app) questionComposeWords() string { room := a.qroom + keys, words := a.questionRoomSends() parts := make([]string, 0, 4) switch { - case len(room.picked) > 0: + case len(keys) > 0: + parts = append(parts, questionAnsweringWord+a.questionPickedWord(keys)) case room.input.kind == session.InputBlanks || room.input.kind == session.InputPairs || room.input.kind == session.InputDial || room.input.kind == session.InputText: parts = append(parts, questionFilledWord) - default: + case words == "": parts = append(parts, questionNoPickWord) } - if len(room.picked) > 0 { - parts = append(parts, questionAnsweringWord+a.questionPickedWord()) - } - if said := strings.TrimSpace(a.input.String()); said != "" { - parts = append(parts, questionWithWord+said) + if words != "" { + parts = append(parts, questionWithWord+words) } if n := len(room.comments); n > 0 { parts = append(parts, strconv.Itoa(n)+questionNotesWord) } - // THE SCOPE IS ON THE ROW ONLY WHERE THERE IS A CHOICE OF IT. A question that - // offered one scope has no decision to show, and drawing `once` beside every - // answer would teach people to stop reading the word. - if len(room.head.question.Scope) > 1 { + // THE SCOPE IS SAID ONLY WHERE THERE IS A CHOICE OF IT. A question that + // offered one lifetime has no decision to show, and drawing `once` beside + // every answer would teach people to stop reading the word. + if len(questionScopes(room.head.question)) > 0 { parts = append(parts, questionScopeWord(room.scope)) } line := strings.Join(parts, questionSep) - if len(room.picked) == 0 { - return a.pal.dim(fit(line, width)) + if len(keys) == 0 && words == "" { + return a.pal.dim(line) } - return a.pal.ink(fit(line, width)) + return a.pal.ink(line) } -// questionPickedWord is what was chosen, in keys and words: `1 postgres`, or +// questionPickedWord is what would be sent, in keys and words: `1 postgres`, or // `1 postgres, 3 a file per day` on a checklist. -func (a *app) questionPickedWord() string { +func (a *app) questionPickedWord(keys []string) string { room := a.qroom - words := make([]string, 0, len(room.picked)) - for _, key := range room.picked { + words := make([]string, 0, len(keys)) + for _, key := range keys { if opt, ok := room.head.question.Option(key); ok && strings.TrimSpace(opt.Label) != "" { words = append(words, key+" "+strings.TrimSpace(opt.Label)) continue @@ -1148,16 +827,46 @@ func (a *app) questionOfferKeys() []questionVerb { return a.questionAnswerKeys(a.qroom.head, formsRoom) } -// questionOfferRow spells that offer as the one row a person reads. The words -// and the give-up order are the table's; the painting is -// [app.paintQuestionKeys]'s, so the foot of a page and the answers row of a card -// are the same row drawn in two places. +// questionRoomOfferRow is the foot's row of keys: the keys that answer at the +// left, and the ones that say something about the decision at the right — the +// panel's two tiers (hints pick A), laid on one row because the page has the +// width the panel's frame does not. +// +// BOTH HALVES ARE THE ONE KEY ROW ([app.questionKeyRow]), each dropped by rank +// until it fits, and the quieter half is given up whole before the half that +// answers loses anything. func (a *app) questionRoomOfferRow(width int) string { - // THE ONE KEY ROW (questionkeys.go's [app.questionKeyRow]): the key in the - // payload hue, its word dim, dropped by rank until it fits. The page used to - // paint its own, in its own hues, which was one of four paintings of one - // grammar on this surface. - return a.questionKeyRow(a.qroom.head, a.questionOfferKeys(), width) + head := a.qroom.head + keys := a.questionOfferKeys() + room := max(width-2*len(questionIndent), 1) + left := a.questionKeyRow(head, questionKeysOnTier(keys, keyPrimary), room) + row := questionIndent + left + spare := room - ansi.StringWidth(left) - 2*len(questionKeyGap) + if right, ok := a.questionKeyRowWithin(head, questionKeysOnTier(keys, keySecondary), spare); ok { + row = questionRightAlign(row, right, width-len(questionIndent)) + } + return row +} + +// questionKeyRowWithin is the one key row at a width, or false where not even +// its most valuable key fits — a tier that would be cut mid-word is left off. +func (a *app) questionKeyRowWithin(q questionShown, keys []questionVerb, width int) (string, bool) { + if len(keys) == 0 || width <= 0 { + return "", false + } + for { + if ansi.StringWidth(questionKeyWords(q, keys)) <= width { + return a.paintQuestionKeys(q, keys), true + } + if len(keys) == 1 { + return "", false + } + dropped, ok := questionDropVerb(keys) + if !ok { + return "", false + } + keys = dropped + } } // ───────────────────────────────────────────────────────────────────────────── @@ -1202,7 +911,13 @@ func (a *app) questionRoomKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { case "enter": return a.questionRoomEnter(), true case "up", "down": - a.questionMoveFocus(map[string]int{"up": -1, "down": 1}[key]) + // WHILE THE ARROWS ARE THE EVIDENCE PANE'S (`→`) THEY SCROLL IT, and + // everywhere else they walk the answers. + if delta := map[string]int{"up": -1, "down": 1}[key]; room.reading { + a.questionRoomScrollDetail(delta) + } else { + a.questionMoveFocus(delta) + } return nil, true } // AND THE BOX IS A BOX WHILE IT IS POINTED AT A PART. `c`, `?` and `n` each @@ -1236,20 +951,23 @@ func (a *app) questionRoomKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { room.refused = "" a.questionRoomTouched() } - // `→` OPENS AN ANSWER AND `←` FOLDS IT, which is the other half of the walk - // `↑`/`↓` began: a person reading a page of folded sections reaches sideways - // to open one, and on this page they were reaching at nothing at all — the - // row said `[←→] choose` and neither key did anything (the owner, - // 2026-09-10: "no arrow or click"). + // `→` HANDS THE ARROWS TO THE EVIDENCE PANE AND `←` HANDS THEM BACK, which + // is the sideways half of the walk `↑`/`↓` began: the pane beside the list + // can be taller than the page, and a diagram whose end is below the fold is + // read by moving INTO the pane rather than by reaching for the wheel. // - // IT IS READ AFTER THE BOX AND BEFORE THE OFFER TABLE for two separate - // reasons. After the box, because `←` inside a sentence somebody is typing - // is the caret and nothing else. Before the table, because where the - // question carries a shape of its own — a dial, a hole with choices — the - // side arrows are that shape's ([needMoves]), and the two can never both be - // true on one page. + // IT IS READ AFTER THE BOX AND BEFORE THE REST OF THE TABLE for two separate + // reasons. After the box, because `←` inside a sentence somebody is typing is + // the caret and nothing else. Before the rest, because where the question + // carries a shape of its own — a dial, a hole with choices — the side arrows + // are that shape's ([needMoves]), and the two can never both be true on one + // page. A side arrow the foot is not offering does nothing at all, which is + // the page's law about keys it did not draw. if room.input.kind == session.InputNone && (key == "left" || key == "right") { - a.questionFoldFocus(key == "right") + if a.questionRoomOffers(key) { + room.reading = key == "right" + a.questionRoomTouched() + } return nil, true } // NO KEY DOES ANYTHING THAT IS NOT DRAWN ON SCREEN RIGHT NOW, which is the @@ -1307,8 +1025,11 @@ func (a *app) questionRoomKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { head := room.head a.closeQuestionRoom() return a.questionUndo(head), true - case questionOpenKey: - room.open[room.focus] = !room.open[room.focus] + case questionScopeKey: + // `t` CHANGES THE LIFETIME AND NOTHING ELSE, exactly as it does on the + // panel (questionscope.go): nothing is written and nothing is answered, + // and the foot's rule says the lifetime the answer would go out with. + room.scope = questionScopeAfter(room.head.question, room.scope) case questionLaterKey: a.closeQuestionRoom() return nil, true @@ -1343,6 +1064,14 @@ func (a *app) questionRoomOffers(key string) bool { if key == "shift+up" || key == "shift+down" { return true } + case questionDetailKey: + if key == "right" { + return true + } + case questionAnswersKey: + if key == "left" { + return true + } case questionBlankKey: if key == "shift+tab" { return true @@ -1352,25 +1081,16 @@ func (a *app) questionRoomOffers(key string) bool { return false } -// questionMoveFocus walks the sections, and it is bounded rather than wrapping: +// questionMoveFocus walks the answers, and it is bounded rather than wrapping: // a list that jumps from the last row to the first is a list a person loses // their place in. func (a *app) questionMoveFocus(delta int) { room := a.qroom - n := len(room.head.question.Options) - if room.input.kind != session.InputNone { - n = room.input.count() - } + n := a.questionRoomWalk() if n == 0 { return } - at := room.focus + delta - if at < 0 { - at = 0 - } - if at >= n { - at = n - 1 - } + at := questionClamp(room.focus+delta, 0, n-1) if room.input.kind != session.InputNone { room.input.focus = at } @@ -1379,77 +1099,30 @@ func (a *app) questionMoveFocus(delta int) { a.questionRoomTouched() } -// questionRoomShowFocus KEEPS THE FOCUSED ANSWER IN VIEW, and it is the one -// place this page's window is moved for anything other than the wheel. -// -// THE DEFECT IT CLOSES: `↓` walked the focus and nothing scrolled, so on a page -// with an attachment at the top and an answer open — which is most pages worth -// opening — the arrows walked down into rows below the fold and the screen did -// not change. A person pressing an arrow and seeing nothing move reads it as a -// key that does nothing. -// -// IT SCROLLS THE LEAST IT CAN, so a page that is already showing the row it -// walked to does not jump: the row is brought just inside the top or the bottom -// edge and no further, which is the reading position a person built by -// scrolling being kept rather than re-anchored under them. -func (a *app) questionRoomShowFocus() { - room := a.qroom - height := a.viewHeight() - if room == nil || height <= 0 { - return - } - // THE ROWS ARE ASKED FOR AFTER THE MOVE, because the map from row to answer - // is written by the drawing ([questionRoom.spots]) and the move may have - // opened or folded a section under it. - rows := a.questionRoomRows(a.bodyWidth()) - first, last := -1, -1 - for at, of := range room.spots { - if of != room.focus { - continue - } - if first < 0 { - first = at - } - last = at - } - if first < 0 { - return - } - switch { - case first < room.offset: - room.offset = first - case last >= room.offset+height: - room.offset = last - height + 1 - } - room.offset = questionClamp(room.offset, 0, max(0, len(rows)-height)) -} - -// questionFoldFocus opens the answer the focus is on, or folds it. -// -// AN ANSWER THAT IS ALREADY OPEN DOES NOT RE-OPEN AND ONE ALREADY FOLDED DOES -// NOT RE-FOLD, which is what makes holding `→` down safe: the key states what -// it wants rather than toggling, so a person who cannot see whether a section -// is open still knows exactly what each arrow will do to it. -func (a *app) questionFoldFocus(open bool) { +// questionRoomScrollDetail moves the evidence pane's window by one row while the +// arrows are the pane's, bounded at both ends. +func (a *app) questionRoomScrollDetail(delta int) { room := a.qroom - if room == nil || room.focus < 0 || room.focus >= len(room.head.question.Options) { - return - } - room.open[room.focus] = open + lay := a.questionRoomLay(a.bodyWidth(), a.viewHeight()) + room.detail = questionWindowStart(room.detail+delta, len(lay.pane), lay.region) a.questionRoomTouched() } -// questionFocusOption is the answer the focus is standing on, and false where -// the page is drawing a shape rather than a list of answers. -func (a *app) questionFocusOption() (session.AnswerOption, bool) { - room := a.qroom - if room == nil || room.input.kind != session.InputNone { - return session.AnswerOption{}, false +// questionScopeAfter is the lifetime after `now` in the question's own list of +// them, narrowest first, coming back round to the narrowest after the widest. +// It is the panel's walk (questionscope.go's [questionScopes]) read from the +// page's own state. +func questionScopeAfter(q session.Question, now session.AnswerScope) session.AnswerScope { + scopes := questionScopes(q) + for i, one := range scopes { + if one == now { + return scopes[(i+1)%len(scopes)] + } } - if room.focus < 0 || room.focus >= len(room.head.question.Options) { - return session.AnswerOption{}, false + if len(scopes) > 0 { + return scopes[0] } - return room.head.question.Options[room.focus], true + return now } // questionFocusKey is the part the focus is on, as the key a comment is filed @@ -1465,29 +1138,28 @@ func (a *app) questionFocusKey() string { return room.head.question.Options[room.focus].Key } -// questionRoomPick takes one of the answers by its digit. +// questionRoomPick is a digit on the page: the pointer goes straight to that +// answer, and the evidence beside the list is that answer's. // -// A CHECKLIST ADDS AND EVERY OTHER SHAPE REPLACES. Pressing 2 on a choice means -// "2, not 1"; pressing 2 on a list of things to tick means "and 2" — and a -// single rule for both would either make a choice accumulate or make a checklist -// impossible to fill. +// ON THE PAGE A DIGIT MOVES AND DOES NOT SEND. The page is where a person reads +// before deciding, and the digit is the fastest way to put an answer's evidence +// in front of them; `enter` is the decision. A CHECKLIST TICKS instead, because +// pressing 2 on a list of things to tick means "and 2". func (a *app) questionRoomPick(key string) { room := a.qroom opt, ok := room.head.question.Option(key) if !ok { return } - for i := range room.head.question.Options { - if room.head.question.Options[i].Key == key { - room.focus = i - } + if at, found := questionOptionAt(room.head.question, key); found { + room.focus = at } if room.input.kind == session.InputChecklist { room.picked = questionToggleOne(room.picked, opt.Key) return } - room.picked = []string{opt.Key} - room.open[room.focus] = true + room.reading = false + a.questionRoomShowFocus() } // questionToggleOne adds a key or takes it away, keeping the order the person @@ -1559,30 +1231,20 @@ func (a *app) questionRoomEnter() tea.Cmd { } return a.questionAnswer(session.Answer{Reframe: said, DecidedBy: session.DecidedByPerson}) } + // ENTER SENDS WHAT THE FOOT SAYS IT WOULD ([app.questionRoomSends]): the + // answer the pointer is on with whatever is in the box beside it, which is + // the block's own law arriving on the page (#789: "every answer has a + // pointer the arrows walk and enter takes"). It costs nothing on a page + // nobody has walked, because the page opens where the block's pointer stood. + // // THE EMPTINESS LAW ON THE MOST IMPORTANT KEY ON THE PAGE: `enter` with - // nothing chosen and nothing typed does nothing at all, because there is no - // answer for it to send. The foot already says `nothing chosen yet`. - // ENTER TAKES THE ANSWER THE POINTER IS ON. It is the block's own law - // arriving on the page (#789: "every answer has a pointer the arrows walk - // and enter takes"), and it costs nothing on a page nobody has walked, - // because the page OPENS on the asker's pick — so `enter` straight off a - // fresh room still takes the recommendation, exactly as it always did. - if len(room.picked) == 0 && said == "" { - if opt, ok := a.questionFocusOption(); ok && strings.TrimSpace(opt.Key) != "" { - room.picked = []string{opt.Key} - } else if room.head.question.Pick != nil { - room.picked = []string{room.head.question.Pick.Key} - } - } - if len(room.picked) == 0 && said == "" && room.input.kind == session.InputNone { + // nothing to send — `something else…` and an empty box — does nothing at + // all, and the foot already says `nothing chosen yet`. + keys, words := a.questionRoomSends() + if len(keys) == 0 && words == "" && room.input.kind == session.InputNone { return nil } - answer := session.Answer{ - Picked: append([]string{}, room.picked...), - Change: said, - DecidedBy: session.DecidedByPerson, - } - return a.questionAnswer(answer) + return a.questionAnswer(session.Answer{Picked: keys, Change: words, DecidedBy: session.DecidedByPerson}) } // questionHandOver is the second press of `d`: the asker's own pick, recorded as @@ -1726,132 +1388,6 @@ func questionHasDimensions(q session.Question) bool { return false } -// ───────────────────────────────────────────────────────────────────────────── -// The geometry, on room.go's own terms. - -// questionRoomWindow is the page's visible slice and the padding under it. It is -// [app.window]'s shape over this page's rows and this page's offset, which is -// what makes `esc` restore the conversation without having moved it. -// -// THE PADDING FALLS BELOW A SHORT PAGE, exactly as it does under a short -// conversation and under a short room. A question with two answers reads from -// the top like everything else on this surface. -func (a *app) questionRoomWindow(width, height int) ([]row, int) { - if height <= 0 { - return nil, 0 - } - rows := a.questionRoomRows(width) - offset := a.questionRoomOffsetFor(len(rows), height) - end := min(offset+height, len(rows)) - visible := rows[offset:end] - if pad := height - len(visible); pad > 0 { - return visible, pad - } - return visible, 0 -} - -// questionRoomOffsetFor clamps the reader's position. -// -// IT ANCHORS AT THE TOP AND NEVER AT THE LIVE EDGE, which is where it parts -// company with a node's page. A room follows work that is still arriving, so it -// sticks to the bottom; a question is FINISHED the moment it is drawn — the head -// is the first thing to read and nothing will be appended under it — so a page -// that opened at its foot would open on the least useful row it has. -func (a *app) questionRoomOffsetFor(total, height int) int { - room := a.qroom - bottom := max(0, total-height) - if room == nil || room.offset < 0 { - return 0 - } - return min(room.offset, bottom) -} - -// ───────────────────────────────────────────────────────────────────────────── -// The pointer. - -// questionRoomOptionAt is which answer the pointer is on, and false where it is -// on none of them. -// -// It is [app.roomRowAt]'s arithmetic answered in this page's coordinates — the -// frame pins rows above the body, the page hangs from the top with the slack -// below it, and the window starts wherever the reader scrolled to — read -// against the row-to-answer map the drawing wrote ([questionRoom.spots]). -func (a *app) questionRoomOptionAt(y int) (int, bool) { - room := a.qroom - top := a.bodyTop() - if room == nil || top < 0 { - return 0, false - } - height := a.viewHeight() - rows := a.questionRoomRows(a.bodyWidth()) - offset := a.questionRoomOffsetFor(len(rows), height) - end := min(offset+height, len(rows)) - at := y - top + offset - if at < offset || at >= end || at >= len(room.spots) || room.spots[at] < 0 { - return 0, false - } - return room.spots[at], true -} - -// questionRoomPress resolves a click on the page and reports whether it took -// one. -// -// ONE PRESS OPENS AN ANSWER AND THE NEXT ONE TAKES IT, which is the only shape -// that can be both discoverable and safe. A page where the first click answered -// would answer a question with evidence somebody had not read yet; a page where -// no click ever answered would be a page a person has to leave the mouse to -// finish. So the first press moves the pointer onto that answer and opens it — -// the same thing `↓` then `→` do — and a press on the answer the pointer is -// already standing on, open, is `enter`. -// -// A PRESS ANYWHERE ELSE ON THE PAGE FALLS THROUGH AND DOES NOTHING, which is -// this surface's law about empty space (app.go's [app.press]): the bodies, the -// diagrams and the person's own notes are prose, and prose has no gesture. -// -// IT IS ASKED ABOUT THE ROW ALONE AND NOT ABOUT THE COLUMN, which is the one -// place this page parts company with a run's. An answer's row is the whole -// width of the page — the fold mark at one end and the pick's badge at the -// other — so a press that had to land on the word would be a press a person has -// to aim. -func (a *app) questionRoomPress(y int) (tea.Cmd, bool) { - room := a.qroom - if room == nil { - return nil, false - } - // THE SETTLE GUARD IS THE POINTER'S TOO. A click that arrived less than - // [questionSettle] after the page was drawn was aimed at whatever was on - // screen before it, and a mouse is no better at stopping than a hand is. - if a.now().Sub(room.shown) < questionSettle { - return nil, true - } - at, ok := a.questionRoomOptionAt(y) - if !ok { - return nil, false - } - if at != room.focus || !room.open[at] { - room.focus, room.open[at] = at, true - a.questionRoomTouched() - return nil, true - } - if key := strings.TrimSpace(room.head.question.Options[at].Key); key != "" { - a.questionRoomPick(key) - } - a.questionRoomTouched() - return a.questionRoomEnter(), true -} - -// questionRoomScroll moves the page's window. -func (a *app) questionRoomScroll(delta int) { - room := a.qroom - if room == nil { - return - } - total := len(a.questionRoomRows(a.bodyWidth())) - bottom := max(0, total-a.viewHeight()) - room.offset = questionClamp(room.offset+delta, 0, bottom) - a.touch() -} - // askBackCmd sends one sentence to the asker WITH THE QUESTION STILL OPEN. // // DESIGN.md gives this two seams — "sent to the asker through ResolveQuestion's diff --git a/internal/tui3/questionroom_test.go b/internal/tui3/questionroom_test.go index a2d34ef1a1..35d0a7abfd 100644 --- a/internal/tui3/questionroom_test.go +++ b/internal/tui3/questionroom_test.go @@ -46,13 +46,20 @@ func newAnsweringAgent() *answeringAgent { } // standingInAQuestion is a surface with the worked example open and the settle -// guard already spent. +// guard already spent, at a width that draws the page as ONE column. func standingInAQuestion(t *testing.T, q session.Question) (*app, *answeringAgent) { + t.Helper() + return standingInAQuestionAt(t, q, 92, 40) +} + +// standingInAQuestionAt is the same page at a size of the test's choosing — a +// hundred columns and wider draws it as two panes. +func standingInAQuestionAt(t *testing.T, q session.Question, width, height int) (*app, *answeringAgent) { t.Helper() agent := newAnsweringAgent() a := newTestApp(agent) - a.width, a.height = 92, 30 - a.raiseQuestionRoom(questionShown{question: q, shown: a.now()}) + a.width, a.height = width, height + a.raiseQuestionRoom(questionShown{question: q, shown: a.now(), pick: questionPointerStart(q)}) a.qroom.shown = a.qroom.shown.Add(-time.Second) return a, agent } @@ -141,7 +148,7 @@ func TestThePickCarriesItsReasonConfidenceAndWhatWouldChangeIt(t *testing.T) { a, _ := standingInAQuestion(t, demoQuestionReading()) drawn := pageText(a) for _, want := range []string{ - questionPickWord, + questionRecommendedWord, "fairly sure", "because it is the only store the reporting job already reads", questionWouldSwitchWord + "the ledger ever has to run on a machine with no server on it", @@ -152,29 +159,34 @@ func TestThePickCarriesItsReasonConfidenceAndWhatWouldChangeIt(t *testing.T) { } } -// THE PAGE OPENS ON THE PICK AND FOLDS THE REST. A page that opened everything -// is a wall, and one that opened nothing makes a person press a key to read the -// answer the asker would take. -func TestThePageOpensOnThePickAndFoldsTheRest(t *testing.T) { - a, _ := standingInAQuestion(t, demoQuestionReading()) - drawn := pageText(a) - if !strings.Contains(drawn, "Rows already carry a foreign key") { - t.Errorf("the pick should be open:\n%s", drawn) - } - if strings.Contains(drawn, "One file in the repository") { - t.Errorf("everything but the pick should be folded:\n%s", drawn) - } - if !strings.Contains(drawn, tokens.GlyphCollapsed) || !strings.Contains(drawn, tokens.GlyphExpanded) { - t.Errorf("the sections should draw both fold marks:\n%s", drawn) +// THE PAGE OPENS ON THE PICK WITH ITS EVIDENCE, AND EVERY OTHER ANSWER IS A ROW. +// A page that drew every answer's evidence is a wall, and one that drew none +// makes a person press a key to read the answer the asker would take. There are +// no sections to fold any more: the evidence follows the pointer. +func TestThePageOpensOnThePickWithItsEvidence(t *testing.T) { + for _, width := range []int{92, 140} { + a, _ := standingInAQuestionAt(t, demoQuestionReading(), width, 40) + drawn := pageText(a) + if !strings.Contains(drawn, "Rows already carry a foreign key") { + t.Errorf("at %d columns the pick's evidence should be on the page:\n%s", width, drawn) + } + if strings.Contains(drawn, "One file in the repository") { + t.Errorf("at %d columns only the answer the pointer is on shows its evidence:\n%s", width, drawn) + } + // The collapsed mark shares its byte with the pointer, so the open one is + // the fold mark a page of sections could not draw without. + if strings.Contains(drawn, tokens.GlyphExpanded) { + t.Errorf("at %d columns the page still draws fold marks:\n%s", width, drawn) + } } } -// A DIGIT TAKES AN ANSWER AND OPENS IT, and the foot says what enter would send. +// A DIGIT MOVES THE POINTER TO ITS ANSWER, and the foot says what enter would send. func TestADigitTakesAnAnswerAndTheFootSaysWhatWouldBeSent(t *testing.T) { a, _ := standingInAQuestion(t, demoQuestionReading()) tap(t, a, "2") if drawn := pageText(a); !strings.Contains(drawn, "One file in the repository") { - t.Errorf("picking an answer should open it:\n%s", drawn) + t.Errorf("a digit should put its answer's evidence on the page:\n%s", drawn) } if foot := footText(a); !strings.Contains(foot, questionAnsweringWord+"2 sqlite beside the project") { t.Errorf("the foot should say what would be sent:\n%s", foot) @@ -192,13 +204,21 @@ func TestTheFootOffersNoAnswerUntilThereIsOne(t *testing.T) { q := demoQuestionReading() q.Pick = nil a, _ := standingInAQuestion(t, q) - if foot := footText(a); !strings.Contains(foot, questionNoPickWord) { - t.Errorf("the foot should say nothing is chosen:\n%s", foot) + if foot := footText(a); !strings.Contains(foot, questionAnsweringWord+"1 postgres") { + t.Errorf("with no pick the foot answers the pointer's own answer:\n%s", foot) + } + // AND ON `something else…` WITH AN EMPTY BOX THERE IS NOTHING TO SEND. + for i := 0; i < len(q.Options); i++ { + tapNamed(t, a, tea.KeyDown, 0) + } + foot := footText(a) + if !strings.Contains(foot, questionNoPickWord) || strings.Contains(foot, questionKeyWord(questionEnterKey)) { + t.Errorf("the other row with nothing typed should offer nothing to send:\n%s", foot) } bare := demoQuestionReading() bare.Pick, bare.Options, bare.Attach = nil, nil, nil a, _ = standingInAQuestion(t, bare) - foot := footText(a) + foot = footText(a) if !strings.Contains(foot, questionNoPickWord) { t.Errorf("the foot should say nothing is chosen:\n%s", foot) } @@ -460,13 +480,13 @@ func TestTheSettleGuardDropsAKeyThatWasAlreadyTravelling(t *testing.T) { a.width, a.height = 92, 30 a.raiseQuestionRoom(questionShown{question: demoQuestionReading(), shown: a.now()}) tap(t, a, "2") - if got := a.qroom.picked; len(got) != 0 { - t.Errorf("a key inside the settle window must be dropped, got %#v", got) + if got := a.qroom.focus; got != 0 { + t.Errorf("a key inside the settle window must be dropped, the pointer moved to %d", got) } a.qroom.shown = a.qroom.shown.Add(-questionSettle) tap(t, a, "2") - if got := a.qroom.picked; len(got) != 1 || got[0] != "2" { - t.Errorf("a key after the settle window should be taken, got %#v", got) + if got := a.qroom.focus; got != 1 { + t.Errorf("a key after the settle window should be taken, the pointer is on %d", got) } } @@ -829,13 +849,12 @@ func pagePlainRows(a *app) []string { return out } -// questionRoomRowY is the screen row one of the page's rows is drawn on, so a -// test can press what a person presses rather than calling the hit-test's own -// arithmetic back at it. +// questionRoomRowY is the screen row one of the page's drawn rows stands on, so +// a test can press what a person presses rather than calling the hit-test's own +// arithmetic back at it. The page's rows ARE its window: they hang from the top +// of the body region. func questionRoomRowY(a *app, at int) int { - rows := a.questionRoomRows(a.bodyWidth()) - offset := a.questionRoomOffsetFor(len(rows), a.viewHeight()) - return a.bodyTop() + at - offset + return a.bodyTop() + at } // questionRoomOptionRow is where one ANSWER's own row landed, by the map the @@ -843,8 +862,8 @@ func questionRoomRowY(a *app, at int) int { func questionRoomOptionRow(t *testing.T, a *app, want int) int { t.Helper() a.questionRoomRows(a.bodyWidth()) - for at, owner := range a.qroom.spots { - if owner == want { + for at, spot := range a.qroom.spots { + if spot.at == want { return at } } @@ -852,34 +871,71 @@ func questionRoomOptionRow(t *testing.T, a *app, want int) int { return -1 } -// `↑` AND `↓` WALK THE ANSWERS AND `→`/`←` OPEN AND FOLD ONE. Every one of them -// did nothing before, on a page whose own foot named a pair of arrows. -func TestTheArrowsWalkTheAnswersAndOpenAndFoldThem(t *testing.T) { - a, _ := standingInAQuestion(t, demoQuestionReading()) - if a.qroom.focus != 0 { - t.Fatalf("the page should open on the pick, not on %d", a.qroom.focus) - } - tapNamed(t, a, tea.KeyDown, 0) - if a.qroom.focus != 1 { - t.Errorf("down should walk to the second answer, got %d", a.qroom.focus) +// `↑` AND `↓` WALK THE ANSWERS AND THE EVIDENCE FOLLOWS. In one column the +// answer the pointer reaches unfolds under its row and the one it left folds +// back to a row; in two panes the pane beside the list changes to it. +func TestTheArrowsWalkTheAnswersAndTheEvidenceFollows(t *testing.T) { + for _, width := range []int{92, 140} { + a, _ := standingInAQuestionAt(t, demoQuestionReading(), width, 40) + if a.qroom.focus != 0 { + t.Fatalf("the page should open on the pick, not on %d", a.qroom.focus) + } + tapNamed(t, a, tea.KeyDown, 0) + if a.qroom.focus != 1 { + t.Errorf("at %d columns down should walk to the second answer, got %d", width, a.qroom.focus) + } + drawn := pageText(a) + if !strings.Contains(drawn, "One file in the repository") || strings.Contains(drawn, "Rows already carry") { + t.Errorf("at %d columns the evidence should be the second answer's and only its:\n%s", width, drawn) + } + tapNamed(t, a, tea.KeyUp, 0) + if a.qroom.focus != 0 { + t.Errorf("at %d columns up should walk back, got %d", width, a.qroom.focus) + } } - if a.qroom.open[1] { - t.Errorf("walking to an answer must not open it") +} + +// `→` HANDS THE ARROWS TO THE EVIDENCE PANE AND `←` HANDS THEM BACK, and only +// where there IS a pane: on a page of one column the side arrows do nothing and +// the foot does not name them. +func TestTheSideArrowsMoveBetweenTheListAndTheEvidence(t *testing.T) { + q := demoQuestionReading() + q.Options[0].Blocks = []session.Block{{Kind: session.BlockDiagram, Title: "a tall one", + Body: strings.Repeat("row of the diagram\n", 40)}} + a, _ := standingInAQuestionAt(t, q, 140, 30) + if foot := footText(a); !strings.Contains(foot, questionDetailKey+" detail") { + t.Fatalf("two panes should offer the way into the evidence:\n%s", foot) } tapNamed(t, a, tea.KeyRight, 0) - if !a.qroom.open[1] { - t.Errorf("right should open the answer under the pointer") + if !a.qroom.reading { + t.Fatal("right should hand the arrows to the evidence pane") } - if drawn := pageText(a); !strings.Contains(drawn, "One file in the repository") { - t.Errorf("the opened answer's body should be on the page:\n%s", drawn) + foot := footText(a) + if !strings.Contains(foot, questionWalkDownKey+" scroll") || !strings.Contains(foot, questionAnswersKey+" back to the answers") { + t.Errorf("while the arrows are the pane's the foot should say so:\n%s", foot) + } + before := pageText(a) + tapNamed(t, a, tea.KeyDown, 0) + if a.qroom.focus != 0 || a.qroom.detail != 1 { + t.Errorf("down should scroll the pane and leave the pointer: focus %d detail %d", a.qroom.focus, a.qroom.detail) + } + if pageText(a) == before { + t.Error("scrolling the pane changed nothing on screen") } tapNamed(t, a, tea.KeyLeft, 0) - if a.qroom.open[1] { - t.Errorf("left should fold the answer under the pointer") + tapNamed(t, a, tea.KeyDown, 0) + if a.qroom.reading || a.qroom.focus != 1 || a.qroom.detail != 0 { + t.Errorf("left should hand the arrows back, and the next pane opens at its top: reading %v focus %d detail %d", + a.qroom.reading, a.qroom.focus, a.qroom.detail) + } + + narrow, _ := standingInAQuestionAt(t, q, 92, 30) + if foot := footText(narrow); strings.Contains(foot, questionDetailKey+" detail") { + t.Errorf("one column has no pane to move into, and must not offer one:\n%s", foot) } - tapNamed(t, a, tea.KeyUp, 0) - if a.qroom.focus != 0 { - t.Errorf("up should walk back, got %d", a.qroom.focus) + tapNamed(t, narrow, tea.KeyRight, 0) + if narrow.qroom.reading { + t.Error("a side arrow the foot does not name must do nothing") } } @@ -904,10 +960,10 @@ func TestEnterTakesTheAnswerThePointerIsOn(t *testing.T) { } } -// A CLICK OPENS AN ANSWER AND A SECOND CLICK ON IT TAKES IT. One press to read -// and one to decide: a page where the first click answered would answer with -// evidence somebody had not read, and a page where no click ever answered would -// be one a person has to leave the mouse to finish. +// A CLICK MOVES ONTO AN ANSWER AND A SECOND CLICK ON IT TAKES IT. One press to +// read and one to decide: a page where the first click answered would answer +// with evidence somebody had not read, and a page where no click ever answered +// would be one a person has to leave the mouse to finish. func TestAClickOpensAnAnswerAndASecondClickTakesIt(t *testing.T) { a, agent := standingInAQuestion(t, demoQuestionReading()) at := questionRoomOptionRow(t, a, 2) @@ -915,9 +971,9 @@ func TestAClickOpensAnAnswerAndASecondClickTakesIt(t *testing.T) { if a.qroom == nil { t.Fatalf("one click must not answer the question") } - if a.qroom.focus != 2 || !a.qroom.open[2] { - t.Fatalf("a click should move the pointer onto that answer and open it: focus %d open %v", - a.qroom.focus, a.qroom.open[2]) + if a.qroom.focus != 2 || !strings.Contains(pageText(a), "Append-only, one file a day") { + t.Fatalf("a click should move the pointer onto that answer and show its evidence: focus %d\n%s", + a.qroom.focus, pageText(a)) } if len(agent.answers) != 0 { t.Fatalf("one click must not answer the question: %+v", agent.answers) @@ -945,6 +1001,16 @@ func TestThePointerLightsTheAnswerAPressWouldTake(t *testing.T) { if hot := a.hoverTarget(8, body); hot.kind == hoverQuestionOption { t.Errorf("an answer's body is prose and must not light as a target: %+v", hot) } + // AND IN TWO PANES THE EVIDENCE BESIDE AN ANSWER'S ROW IS NOT THAT ANSWER: + // the row is a target on the left of the seam and prose on the right of it. + wide, _ := standingInAQuestionAt(t, demoQuestionReading(), 140, 40) + row := questionRoomRowY(wide, questionRoomOptionRow(t, wide, 1)) + if hot := wide.hoverTarget(4, row); hot.kind != hoverQuestionOption || hot.index != 1 { + t.Errorf("the list's row should light its answer: %+v", hot) + } + if hot := wide.hoverTarget(wide.qroom.seam+4, row); hot.kind == hoverQuestionOption { + t.Errorf("the evidence beside a row must not light as that answer: %+v", hot) + } } // ── THE HIERARCHY ─────────────────────────────────────────────────────────── @@ -967,15 +1033,15 @@ func TestAnOpenAnswerSaysWhichOfItsLinesIsWhich(t *testing.T) { } } -// AND THE THREE TIERS ARE THREE INKS: the label is the question hue and the -// weight together, the body is the prose ink, and every aside under it is dim. +// AND THE THREE TIERS ARE THREE INKS: the answer's row is the panel's own, the +// body is the prose ink, and every aside under it is dim. func TestAnAnswersLabelBodyAndAsidesAreThreeDifferentInks(t *testing.T) { a, _ := standingInAQuestion(t, demoQuestionReading()) rows := a.questionRoomRows(a.width) var head, body, aside string for _, r := range rows { switch { - case strings.Contains(plain(r.text), "▾ 1 postgres"): + case strings.Contains(plain(r.text), "1 postgres"): head = r.text case strings.Contains(plain(r.text), "Rows already carry a foreign key"): body = r.text @@ -986,12 +1052,12 @@ func TestAnAnswersLabelBodyAndAsidesAreThreeDifferentInks(t *testing.T) { if head == "" || body == "" || aside == "" { t.Fatalf("the open answer should draw a heading, a body and an aside:\n%s", pageText(a)) } - // THE KEY IS THE PAYLOAD HUE AND THE WORD IS BOLD INK, which is the panel's - // own grammar for an answer (owner ruling 2026-09-11, colour pick C: the - // amber stays on the marks). - if !strings.Contains(head, a.pal.data("1")) || - !strings.Contains(head, a.pal.bold(a.pal.ink("postgres"))) { - t.Errorf("the label is not the key in the payload hue and the word in bold ink: %q", head) + // THE KEY IS THE PAYLOAD HUE AND THE WORD IS INK, which is the panel's own + // grammar for an answer (owner ruling 2026-09-11, colour pick C: the amber + // stays on the marks) — the page's rows ARE the panel's. + inkOpen := strings.SplitN(a.pal.ink("x"), "x", 2)[0] + if !strings.Contains(head, a.pal.data("1")) || !strings.Contains(head, inkOpen+"postgres") { + t.Errorf("the label is not the key in the payload hue and the word in ink: %q", head) } if !strings.Contains(body, a.pal.ink("Rows already carry a foreign key into it and the migration is one file.")) { t.Errorf("the body should be the prose ink: %q", body) @@ -1032,19 +1098,19 @@ func TestWhatWouldChangeItsMindReadsAsOneClause(t *testing.T) { // ── THE EVIDENCE, LAID OUT WITH WHAT IT BELONGS TO ────────────────────────── -// AN ANSWER'S OWN BLOCKS ARE DRAWN UNDER IT, at its body's indent, with a blank -// row before each. +// AN ANSWER'S OWN BLOCKS ARE DRAWN WITH ITS EVIDENCE, at its body's indent, with +// a blank row before each — under its row in one column. func TestAnAnswersBlocksAreDrawnUnderTheAnswer(t *testing.T) { a, _ := standingInAQuestion(t, demoQuestionReading()) rows := pagePlainRows(a) head, title := -1, -1 for at, line := range rows { switch { - case strings.Contains(line, "▾ 1 postgres"): + case strings.Contains(line, "1 postgres"): head = at case strings.Contains(line, "what it would look like"): title = at - case strings.Contains(line, "▸ 2 sqlite") && title < 0: + case strings.Contains(line, "2 sqlite") && title < 0: t.Fatalf("the block should be drawn before the next answer:\n%s", strings.Join(rows, "\n")) } } @@ -1059,9 +1125,11 @@ func TestAnAnswersBlocksAreDrawnUnderTheAnswer(t *testing.T) { } } -// AND THE QUESTION'S OWN EVIDENCE IS ONE TITLED SECTION ABOVE THE ANSWERS, -// never a run of unowned pictures at the gutter after them. -func TestTheQuestionsOwnEvidenceIsOneTitledSectionAboveTheAnswers(t *testing.T) { +// AND THE QUESTION'S OWN EVIDENCE IS ONE TITLED SECTION UNDER THE ANSWERS, in +// the list's own column (page pick A): it is the same whichever answer is being +// weighed, so it stands with the thing that stays still and never in the pane +// that changes with the pointer. +func TestTheQuestionsOwnEvidenceIsOneTitledSectionUnderTheAnswers(t *testing.T) { q := demoQuestionReading() q.Attach = []session.Block{ {Kind: session.BlockDiagram, Title: "where the rows are today", Body: "app --> sqlite"}, @@ -1076,15 +1144,21 @@ func TestTheQuestionsOwnEvidenceIsOneTitledSectionAboveTheAnswers(t *testing.T) section = at case strings.Contains(line, "where the rows are today"): first = at - case strings.Contains(line, "▾ 1 postgres"): + case strings.Contains(line, "3 a file per day"): answers = at } } if section < 0 || first < 0 || answers < 0 { t.Fatalf("the page should title the question's own evidence:\n%s", strings.Join(rows, "\n")) } - if !(section < first && first < answers) { - t.Errorf("the evidence should be one section above the answers: %d %d %d", section, first, answers) + if !(answers < section && section < first) { + t.Errorf("the evidence should be one section under the answers: %d %d %d", answers, section, first) + } + wide, _ := standingInAQuestionAt(t, q, 140, 40) + for _, line := range pagePlainRows(wide) { + if at := strings.Index(line, "where the rows are today"); at >= 0 && at > wide.qroom.seam { + t.Errorf("the question's own evidence belongs left of the seam, under the list: %q", line) + } } if !strings.HasPrefix(rows[section], questionIndent) || strings.HasPrefix(rows[section], questionIndent+" ") { t.Errorf("the section's title should sit at the page's own indent: %q", rows[section]) diff --git a/internal/tui3/questionscope.go b/internal/tui3/questionscope.go index 70ed5533a6..cb4ccf8a27 100644 --- a/internal/tui3/questionscope.go +++ b/internal/tui3/questionscope.go @@ -124,23 +124,14 @@ func (a *app) questionScopeRow(q questionShown, room int) string { // the lifetime travels with the answer when one is given ([questionScopeOf]), // so a person who cycles this and then presses `esc` has written no rule, which // is what makes it safe to press. +// THE WALK ITSELF IS [questionScopeAfter] AND IS NOT SPELLED TWICE. This is the +// key — it finds the question the key was pressed on and marks the surface dirty; +// which lifetime comes next is one rule, asked here and by the page's own `t`. func (a *app) questionScopeNext(head questionShown) { open := a.questionHeld(head.token()) - if open == nil { + if open == nil || len(questionScopes(open.question)) == 0 { return } - scopes := questionScopes(open.question) - if len(scopes) == 0 { - return - } - now := questionScopeNow(*open) - for i, one := range scopes { - if one == now { - open.scope = scopes[(i+1)%len(scopes)] - a.touch() - return - } - } - open.scope = scopes[0] + open.scope = questionScopeAfter(open.question, questionScopeNow(*open)) a.touch() } diff --git a/internal/tui3/questionscreens_test.go b/internal/tui3/questionscreens_test.go index a8567a15d6..d388c3fd81 100644 --- a/internal/tui3/questionscreens_test.go +++ b/internal/tui3/questionscreens_test.go @@ -49,7 +49,11 @@ func TestQuestionScreens(t *testing.T) { // `held` is the ONE case whose whole point is that the block draws // nothing: a question waiting behind a half-typed sentence, counted in // the status line and nowhere else. - if len(lab.rows()) == 0 && name != "held" { + drew := len(lab.rows()) > 0 + if lab.a.questionRoomOpen() { + drew = len(lab.a.questionRoomRows(lab.a.width)) > 0 + } + if !drew && name != "held" { t.Fatalf("%s drew nothing", name) } if dir == "" { @@ -228,6 +232,62 @@ func TestQuestionScreens(t *testing.T) { }) l.rows() }) + + // THE EVIDENCE, AT THE THREE WIDTHS THE OWNER PICKED FOR (2026-09-11). + // Preview A beside the list at a hundred columns and wider, preview-narrow B + // unfolded under the pointer below that, and page A — the page `o` opens — + // as two panes where they fit and one column where they do not. Each is the + // same answer's evidence through the same renderer, so the six pictures are + // one account of it drawn at three widths. + for _, width := range []int{120, 96, 56} { + shot("evidence-"+itoa(width), func(l *questionLab) { + l.a.width, l.a.height = width, 40 + l.raise(demoQuestionReading()) + l.tick(questionSettle) + }) + shot("page-"+itoa(width), func(l *questionLab) { + l.a.width, l.a.height = width, 40 + // THE COLUMN STOWED (`ctrl+g`), so the page has the frame's width + // to split: the page is drawn in the body, beside the task column + // where one stands, and `page-120-column` is the same page with it up. + l.a.railAway = true + l.raise(demoQuestionReading()) + l.tick(questionSettle) + l.press("o") + }) + } + // THE PAGE BESIDE THE TASK COLUMN. A hundred and twenty columns less the + // column's own is a body under the width two panes need, so the page draws + // its one column — the evidence unfolded under the pointer — rather than + // two panes too narrow to read. + shot("page-120-column", func(l *questionLab) { + l.a.width, l.a.height = 120, 40 + l.raise(demoQuestionReading()) + l.tick(questionSettle) + l.press("o") + }) + // AND THE SPLIT PAGE BESIDE THE TASK COLUMN, which is the one combination + // every other shot here stowed the column to avoid. A hundred and forty + // columns less the column's own leaves a body still over [railSlimFloor], so + // the page splits AND the rail stands — and that is exactly where the page's + // two rules were drawn in different frames: the top rule in the body, the + // foot's rule in the full terminal, running on under the rail. A picture of + // it is the only thing that would have shown it, so there is one now. + shot("page-140-column", func(l *questionLab) { + l.a.width, l.a.height = 140, 40 + l.raise(demoQuestionReading()) + l.tick(questionSettle) + l.press("o") + }) + // AND A LAYOUT BLOCK ON THE PAGE, whose two panes stand side by side when + // each one's widest line fits its half and stack when one does not. + shot("page-layout-120", func(l *questionLab) { + l.a.width, l.a.height = 120, 40 + l.a.railAway = true + l.raise(demoQuestionLayout()) + l.tick(questionSettle) + l.press("o") + }) } // TestTheChipIsDrawnOnTheStatusRowWhereEveryPageCanSeeIt is the folded case's diff --git a/internal/tui3/view.go b/internal/tui3/view.go index 39e8168fb5..200a37c8cb 100644 --- a/internal/tui3/view.go +++ b/internal/tui3/view.go @@ -642,7 +642,7 @@ func (a *app) frameLines(rows, chrome []string, height, caretX, caretRow, lift, // [app.chromeAt] resolves a pointer to one of them — three questions that must // never be able to disagree about where the input line is. func (a *app) chrome(width int) ([]string, []chromeRow, int, int) { - roomy := a.breathingRows() > 0 + roomy := a.footClearance() > 0 rows := make([]string, 0, 8) marks := make([]chromeRow, 0, 8) @@ -927,8 +927,13 @@ func (a *app) chromeHeight() int { // on the same clearance ([spacingRuleClearance], pages.go's [placeFrame]). It // used to keep a second blank under the box that no place has, so `esc` from // home into a chat moved the box and the rule up a row (PLACES-AUDIT.md, lane K). +// +// AND A QUESTION'S PAGE BRINGS ITS OWN RULE (questionroom.go's +// [app.questionFootRows]): the rule that closes its two panes meets their seam +// and carries what `enter` would send, so the legend and its blank would be a +// second rule a row above the first, naming keys the page does not take. func (a *app) footClearance() int { - if a.breathingRows() == 0 { + if a.breathingRows() == 0 || a.questionRoomOpen() { return 0 } return spacingRuleClearance