diff --git a/cmd/aforge-demo-home/seed_work.go b/cmd/aforge-demo-home/seed_work.go index 34a4470a48..76e1080e20 100644 --- a/cmd/aforge-demo-home/seed_work.go +++ b/cmd/aforge-demo-home/seed_work.go @@ -174,6 +174,56 @@ var demoTasks = []demoTask{ DurationMS: 7 * 60 * 1000, }, }, + // AND A GROUP OF LANDINGS NOBODY HAS CHECKED, ACROSS THREE PROJECTS. Home's + // `needs you` panel holds them as its `to check` group (homepanel_needs.go), + // and a fixture with ONE of them draws a group of one — which is the shape a + // group line, a count, a clause and a fold all fail to be tested by. These + // are deliberately unlike each other: one wrote seventeen files, one wrote + // none, one has a name long enough to be cut, and one is old enough to age + // onto the fold rather than onto a row. + { + project: firstProjectName, talk: "Reading the Ledger", ago: 26 * time.Hour, + entry: session.TaskIndexEntry{ + ID: "1", Title: "Take the fortnight's spend apart by model", Status: string(session.TaskUnverified), + Outcome: "Opus is four fifths of the fortnight and nearly all of it is one conversation; the split is in the page.", + Files: []string{"docs/design/spend/fortnight.md"}, + FilesChanged: 1, Cost: 0.19, Model: "anthropic/claude-sonnet-4", Tokens: 24_400, + DurationMS: 5 * 60 * 1000, + }, + }, + { + project: firstProjectName, talk: "The Manual's Missing Page", ago: 30 * time.Hour, + entry: session.TaskIndexEntry{ + ID: "1", Title: "Write the page about reading a task, and every question it should answer", + Status: string(session.TaskUnverified), + Outcome: "Seventeen sections, each under two thousand characters, and every probe in the table reaches one of them.", + Files: []string{ + "internal/manual/chat/reading-a-task-page.md", "internal/manual/chat/tasks.md", + "internal/manual/chat/home.md", "internal/manual/chat/keys.md", + }, + FilesChanged: 17, Cost: 0.61, Model: "anthropic/claude-opus-4.1", Tokens: 88_200, + DurationMS: 19 * 60 * 1000, + }, + }, + { + project: "pricing-site", talk: "Pricing Research", ago: 7 * time.Hour, + entry: session.TaskIndexEntry{ + ID: "2", Title: "Read what the four nearest competitors charge", Status: string(session.TaskUnverified), + Outcome: "Three of the four price per seat and one per workspace; nobody could check the figures against a source.", + Cost: 0.08, Model: "anthropic/claude-sonnet-4", Tokens: 11_300, + DurationMS: 3 * 60 * 1000, + }, + }, + { + project: "infra", talk: "Rotating the Deploy Key", ago: 4 * 24 * time.Hour, + entry: session.TaskIndexEntry{ + ID: "1", Title: "Rotate the deploy key and re-fasten the runners", Status: string(session.TaskUnverified), + Outcome: "The key is rotated and both runners took it; the third host was not reachable to prove.", + Files: []string{"terraform/runners.tf", "runbooks/deploy-key.md"}, + FilesChanged: 2, Cost: 0.24, Model: "anthropic/claude-sonnet-4", Tokens: 31_100, + DurationMS: 8 * 60 * 1000, + }, + }, } // writeTaskIndex appends every row to its own project's index and answers how diff --git a/docs/changes/unreleased/900-home-needs-groups.md b/docs/changes/unreleased/900-home-needs-groups.md new file mode 100644 index 0000000000..13d29425fb --- /dev/null +++ b/docs/changes/unreleased/900-home-needs-groups.md @@ -0,0 +1,27 @@ +--- +kind: changed +title: home's `needs you` is two groups — the questions that stopped something, then what to check +pr: 900 +surface: [chat] +invalidates: + - "`needs you` drew a conversation's question, a watch and a landed task as ONE flat list ordered oldest-first. It is TWO GROUPS on one panel: the live questions first (a conversation stopped on a question or a consent card, a watch that needs somebody), longest wait first, and under them a dim group line `to check · N` with the clause `finished, nobody has checked it` at its right, holding every task whose call is the person's, NEWEST first. A live question is always above a landing, however old the landing is." + - "Every landing on home carried the sub-line `landed unchecked · enter to look` (`needsUncheckedWord` in `internal/tui3/placeprose.go`). That constant is DELETED and no row says it. A landing is ONE line — its name and `N files · age` — and only the row under the cursor grows a second line, which is the report's first sentence with the task's own two answers at its right." + - "A landing on `needs you` wore the amber `?` mark. It wears NONE. The mark means a thing has stopped and will not move until somebody answers it, and a landing has already finished; law 8's `two marks` is unchanged and the `?` is now only on a stopped conversation or watch." + - "The heading `needs you · N` counted every row including the landings. It counts the QUESTIONS only; the landings are counted on the group's own line. A frame holding only landings draws `needs you` with no number at all, which is the emptiness law applied to a heading." + - "A digit on home answered THE TOP question of `needs you`, always. It answers THE ONE ROW OF THE FRAME THAT IS DRAWING ITS ANSWERS: the row under the cursor when that row can take one, and the top answerable row otherwise. Drawing and routing read the same function (`app.homeAnswerAt`), so the chip on the screen and the key a person presses can never be two different rows." + - "A landed task could not be answered from home at all — `homeRowAnswers` gated the chips on a live session's presence question, which a `to check` row by construction does not have. A landing on `to check` now draws its own two answers in the TASK'S OWN WORDS (`session.TaskAsk.Yes`/`.No`) and takes them. THE KEYS ON HOME ARE `1` AND `2`, not the `[a]`/`[n]` the landing card, the task's room and its record take: a bare letter on home always types (the box promises `type to search or start something new` and the promise has no asterisk), and the drawn digit is the one printable exception that screen makes. The digit is mapped back to `session.LandingYesKey`/`LandingNoKey` before the answer leaves the surface, so the engine sees the same answer it has always seen." + - "A conversation whose presence says `waiting on you · your call on ` with no question object was drawn as a question on `needs you`, above the `to check` row for the same piece of work — one landing on two rows of one panel. `internal/session`'s `Agent.waitingOnPerson` reads a pending decision last and has no question object to write for it, so this is the ordinary shape of any live conversation sitting on a landed task. The conversation's row is dropped and the landing's row stands: it has the work's own name, its files and its answers." + - "`since you left` listed every landing since the look stamp, including the ones `needs you` was showing as your call. It DROPS a landing that the `to check` group is drawing, and shows it again as an ordinary line once that landing has been answered or has aged out of the group." + - "The pulse's `N want you` counted conversations, watches and errands and never a landed task, so it disagreed with `needs you · N` under it. It is the SUM OF BOTH GROUPS now, counted through the panel's own reading of a landing (`needsCallOf`, `needsAged`) rather than through a second test. The tab bar's `tasks` number is unrelated and unchanged — `placeCounts.ChangedIn` is `how many things moved since you last looked at this place`, and that seam is still nil." + - "A panel squeezed by taking rows off the bottom until it reached its floor, and there was no such thing as a group inside a panel. `homePanelRows.group` is a run of rows at the FOOT of a panel's list drawn under a dim line of its own; because they are last, the ordinary bottom-up cut spends them first, and when none is left the group's line goes and the panel's own fold names the group — `4 to check · 1 older · tasks`. `homeGridPanel.shrink` is now a plain walk down from the natural height rather than the old `least - 2` arithmetic." + - "The demo home (`cmd/aforge-demo-home`) had exactly ONE `unverified` row, which draws a `to check` group of one. It has five, across three projects, deliberately unlike each other: seventeen files, no files at all, a name long enough to be cut, and one old enough to age onto the fold instead of onto a row." +--- + +The note on the panel was that the difference between `needs you` and `to check` is not +obvious, and the fix is one dim clause on the group's line rather than a label on every row. +Seven words, once, in the place a heading's clause already lives — and the cursor row's +`1 accept 2 not right` says what checking one actually means. + +Two things the tmux drive found that unit tests had not: the same landing drawn on two rows +of one panel whenever its conversation is live, and that `a`/`n` cannot be answer keys on a +screen whose foot promises that typing starts a conversation. diff --git a/docs/design/home-mission-control/DESIGN.md b/docs/design/home-mission-control/DESIGN.md index ff8fd07030..7b05c704e3 100644 --- a/docs/design/home-mission-control/DESIGN.md +++ b/docs/design/home-mission-control/DESIGN.md @@ -60,13 +60,29 @@ own keys, each a door to the place that owns it. The chat list is one panel. 5. **A short terminal squeezes in priority order.** `next up` and `spend` give way first, then `since you left`, then `running`; `needs you` and `where you were` shrink last. A squeezed panel keeps its heading and `N more · <place>`. + A GROUP INSIDE A PANEL folds before the panel gives up a row above it: the + rows of `needs you`'s `to check` group sit at the foot of the panel's list, so + the ordinary bottom-up cut spends them first, and when none is left the + group's line goes and the fold names it — `8 to check · tasks` (#884). 6. **Preselect the previous thing.** On double-space the cursor is on the chat you were in before this one (this window's own stack, `chattabs.go` `tabList`). Enter is a switch in two keys; esc goes back. -7. **Digits answer the top question from anywhere on home.** No cursor move. - The answers are drawn on the row so the key is never a guess. +7. **One row of the frame draws its answers, and the key answers it from + anywhere on home.** No cursor move. That row is the one under the cursor when + it can take an answer and the top answerable row otherwise (#884), so a digit + works on a frame nobody has walked and a landing somebody HAS walked onto is + the row the key means. The answers are drawn on the row so the key is never a + guess. THE WORDS ARE THE QUESTION'S OWN EVERYWHERE; the KEYS are too wherever + the question's own keys are digits, which is every card a conversation stops + on. The one exception is a landing in `to check`, whose keys are `[a]`/`[n]` + on its card, in its room and on its record and are `1`/`2` here — because a + bare letter on home types (home.go: the foot promises "type to search or start + something new" and the promise has no asterisk), and the drawn digit is the + one printable exception this screen makes. The digit is mapped back to + `session.LandingYesKey`/`LandingNoKey` before the answer leaves the surface. 8. **Two marks, one accent, one hint line.** `GlyphNeedsHuman` in `hueWarn` for - a person, the single spinner cell for work. Nothing else wears a glyph or a + a person — a conversation or a watch that has STOPPED, never a landing on + `to check`, which has already finished — the single spinner cell for work. Nothing else wears a glyph or a colour. Money is a number. Key hints live on the foot and the alt map, never inside a panel. 9. **Nothing grows past its budget.** Every panel folds inside itself with diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index 4757715ca9..7fa75d212b 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -17,12 +17,17 @@ answering one question you would ask walking up to a colleague's desk: ? Clever Bet Prediction Model 6h npm run dev · pricing-site · a background job the 2024 season only, or all three? enter since you left · 12h - where you were Spark Fleet Ssh Audit · 2 hosts up, 1 not - Understanding Hash Tables here made apartments-minto-street.md - explain open addressing vs chaining - Understanding Bloom Filters 11h spend today $0.14 of $20 - Locate Recent Sandbox Task infra 1d ▁▂▁▃▅▂▁▁▇▃▂▅▂▁ 14 days $34.10 · loudest mon $9.20 - 70 more · type to find one opus 63% · 3 chats and 1 task today + to check · 3 finished, nobody has checked it Spark Fleet Ssh Audit · 2 hosts up, 1 not + tier-B subs 3 files · 1d made apartments-minto-street.md + built the tier-B flow 1 accept 2 not right + package release radar 1d spend today $0.14 of $20 + 1 more · 15 older · tasks ▁▂▁▃▅▂▁▁▇▃▂▅▂▁ 14 days $34.10 · loudest mon $9.20 + opus 63% · 3 chats and 1 task today + where you were + Understanding Hash Tables here next up + explain open addressing vs chaining the 6am repo watch in 20h + Understanding Bloom Filters 11h + 70 more · type to find one projects next up ~/aforge-v2 12 chats · 1 running master the 6am repo watch in 20h @@ -50,7 +55,7 @@ from 170, where `projects` and `spend` take the third. | Panel | What a row is | `enter` on a row | Dim line when it holds nothing | | --- | --- | --- | --- | -| `needs you` | a question waiting on a person, anywhere | opens where it was asked | `questions from any chat or task land here · a digit answers them` | +| `needs you` | a question waiting on a person, anywhere, and under them the `to check` group of work that landed | opens where it was asked | `questions from any chat or task land here · a digit answers them` | | `where you were` | a conversation | opens it | `your conversations · what you type below starts one` | | `projects` | a folder with conversations | starts a new chat there | never empty — the folder this window opened in is always a row | | `running` | a task, a background job, a watch firing | brings its conversation here | `work you send off with /task runs here on its own` | @@ -66,39 +71,95 @@ while a number still draws nothing at zero — never `$0.00`, never `0 tasks`. ## What needs me — the needs you panel, the ? rows at the top of home -**The top of the left column, and its rows wear the amber `?`.** A row is there when +**The top of the left column.** The panel holds **two groups**: the questions that have +stopped something, then the work that landed and has not been checked. + +**The first group is the questions, and only they wear the amber `?`.** A row is there when something has stopped and cannot go on without you, from any project: - a conversation stopped on a question — a command to approve, a task to approve, a card about something standing; -- a task the record marks as `your call` — work nobody could check reads - `landed unchecked · enter to look`, and `enter` opens the conversation on the task's - record, where its two answers are; - a reminder, watch or rule that stopped and wants an answer. -Each row is **two lines**: the title with **how long it has waited** at the right, and under -it what it is asking, in the question's own words. The longest wait is at the top. The -heading counts the rows: `needs you · 2`. +Each is **two lines**: the title with **how long it has waited** at the right, and under it +what it is asking, in the question's own words. **The longest wait is at the top**, and the +heading counts these rows and no others: `needs you · 2`. A frame where nothing is stopped +draws the heading with no number at all. **A permission question is repeated exactly and nothing is added to it.** The session that is stopped writes one sentence — `needs your ok to run ` and the tool's name — and the row is that sentence, whole. A question with a paragraph shows only its first line. -**Only a task's call ages.** A `your call` is a row for two days after it landed; after that -the panel's last line counts it instead — `3 older · tasks` — and `enter` there opens the -tasks place, where every one of them still is. A question a conversation is stopped on, and -a watch that needs somebody, never age off home. +**A question stands above every landing**, however old the landing is: a stopped +conversation is costing you something and a landing is not. Four rows show, eight in a tall window, then `N more · tasks`. Amber is spent on the `?` mark and on nothing else; a machine with nothing waiting has no accent on it at all. -## Answer from home — a digit answers the top question from anywhere +## to check — the group of landings nobody has checked + +**`to check` is every task whose call is yours: work that finished, where nobody could say +whether it is right.** It is a dim line inside the `needs you` panel — `to check · 8` at the +left, and at the right the one clause that says what the group is: + +``` + to check · 8 finished, nobody has checked it + tier-B subs 3 files · 1d + package release radar 1d +``` + +The line is drawn only while the group has rows, and the rows are **newest first** — the +opposite of the questions above them, because nothing here is waiting and the freshest +landing is the one still in your head. + +**A landing is one line at rest**: its name, its file count and how long ago. A landing +with no files to its name draws nothing where the count would be, never `0 files`. + +**The row under the cursor grows a second line** — the first sentence of what the work came +to, and its two answers at the right: -**The top question that has answers draws them on its own row** — `1 allow once 2 always -3 deny`, three at most, then `enter` — and **a digit answers it from anywhere on home**, +``` + tier-B subs 3 files · 1d + built the tier-B subscription flow 1 accept 2 not right +``` + +The two words are the task's own — `accept` and `not right` for work nobody could check, +which is every row that reaches this group. **The two keys are `1` and `2` +here and `[a]`/`[n]` everywhere else**, and that is deliberate: a bare letter on home always +types, because the box under it promises "type to search or start something new" and the +promise has no asterisk. A digit drawn on the row is the one printable exception home makes, +so a landing takes `1` and `2` on this screen and its own letters on its card, in its room +and on its record. `enter` opens the conversation on the task's record. + +**A landing wears no mark.** The `?` means something has stopped and will not move; this +work has already finished. + +**Only a landing ages.** A `your call` is a row for two days after it landed; after that the +panel's last line counts it instead — `3 older · tasks` — and `enter` there opens the tasks +place, where every one of them still is. A question a conversation is stopped on, and a +watch that needs somebody, never age off home. + +**A landing on `to check` is not repeated by `since you left`.** It goes back to that panel +as an ordinary line once it has been answered or has aged out of the group. + +**On a short frame the whole group folds to one line before any question gives way** — +`8 to check · tasks`, which opens the tasks place. + +## Answer from home — a digit answers the question that is drawing its answers + +**Exactly one row of the frame draws its answers** — `1 allow once 2 always 3 deny`, +three at most, then `enter` — and **the key answers that row from anywhere on home**, wherever the cursor is. Every other row says `enter` instead, so one `1` is never on the -screen twice with two meanings. The answers are that question's own, on its own keys: -there is no home-specific `y`/`n` translation anywhere on this screen. +screen twice with two meanings. The words are that question's own and nothing here is put +into a vocabulary of home's; there is no `y`/`n` anywhere on this screen. + +**The keys are the question's own wherever those keys are digits**, which is every card a +conversation stops on. The one place they are not is a landing in `to check`: its keys are +`[a]`/`[n]` everywhere else and `1`/`2` here, because a bare letter on home types. + +**Which row is it?** The row under the cursor when that row can take an answer, and the top +answerable row otherwise. So a digit works on a frame you have not walked, and once you have +walked onto a landing in `to check` its `1`/`2` are the keys that are live. Every kind of question can be answered this way: the whole question is in the file home reads, and the answer goes back through the one door that knows which part of the engine @@ -118,9 +179,10 @@ With something typed in the box a digit is a character going into it, never an a ## What opens when I press a landed row on home — I clicked a needs you row and it opened the chat **A task's row opens its conversation with the task's record in front.** `enter` on a -`your call` row in `needs you` opens the conversation that ran the work, on the task's -record, where the question and its two answers are — whatever project it belongs to, with -the conversation you were in left running behind it. +`to check` row opens the conversation that ran the work, on the task's record, where the +question, its whole report and its two answers are — whatever project it belongs to, with +the conversation you were in left running behind it. You do not have to open it to answer: +`1` and `2` on the row itself send the same two answers. A conversation's own question opens that conversation, where the question is waiting above the box. Work that landed also reaches home through **the `since you left` panel**: a line @@ -142,6 +204,10 @@ Three ways to settle it, and they are the same door: whatever that row is asking (`accept`/`not right`, `resolve it`/`drop it`), plus `[s] tell it`, which opens the task's page rather than answering, and a dimmer `[d] let aforge decide this one`; +- **home** — walk onto its row in `to check` and press `1` or `2`. The conversation applies + it on its own beat; if it is not running, the answer waits on its doorstep and is applied + the next time that conversation runs, and the row says + `answered · waiting for it to pick that up` meanwhile; - **the task's room** — enter on its roster row opens it, and the same chips stand at the foot of the page; `a`, `n`, `s` and `d` over an empty box answer it with nothing selected; @@ -370,7 +436,7 @@ instead, **the foot names the chord that still reaches them** — `ctrl+o open f `ctrl+e pause` on a standing order — and the rest are on their chords too: `ctrl+y` copy path, `ctrl+e` put away. `ctrl+o` on a `projects` row opens that project's folder. -A digit answers the top question in `needs you` wherever you are standing. `enter` acts on +A digit answers the one row of `needs you` drawing its answers, wherever you are standing. `enter` acts on the row under the cursor. `alt+.` draws the map. ## Why is the needs you heading highlighted, why is one project name darker than the others diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index c2afc76933..c047fe2222 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -1984,7 +1984,7 @@ what each holds), and its keys are a small grammar: | --- | --- | | `↑` / `↓` (`ctrl+p` / `ctrl+n`) | walk the column you are in, from one panel into the next; `↑` off the top of a column reaches **the tab bar** (*The tab bar is a row the cursor can stand on*) | | `←` / `→` | cross to the next column, onto the row nearest the one you left — only into a column with a row to stand on | -| a digit | answers **the top question in `needs you`**, from anywhere on home, with no cursor move — its answers are drawn on its own row, `1 allow once 2 always 3 deny` | +| a digit, or a question's own key | answers **the one row of `needs you` that is drawing its answers**, from anywhere on home, with no cursor move — the row under the cursor when it can take one, the top answerable row otherwise. A question's chips are `1 allow once 2 always 3 deny`; a landing in `to check` offers `1 accept 2 not right`, its own `[a]`/`[n]` being letters and letters always type on home | | `enter` | acts on the row under the cursor: a conversation opens, a project row starts a new chat in that folder, a `since you left` line opens its record, file or place, a spend or `next up` row opens spend or standing, a fold line opens the place it names | | `pgup` / `pgdown` | jump a screenful | | `tab` | **the next place** on the bar | diff --git a/internal/manual/chat/places.md b/internal/manual/chat/places.md index abb42edfc9..d012653dab 100644 --- a/internal/manual/chat/places.md +++ b/internal/manual/chat/places.md @@ -416,7 +416,7 @@ project, as **seven panels** in one, two or three columns: `needs you`, `where y empty panel keeps its heading and one dim line naming what arrives there. Its own keys are in the **Home** page: `↑↓` walk a panel, `←→` cross columns, a digit -answers the top question in `needs you`, and `enter` opens a row. `tab` is the way to the +answers the one row of `needs you` drawing its answers, and `enter` opens a row. `tab` is the way to the next place, and the errand pane is taken into with `→` rather than `tab`. Home has no `alt+<letter>` keys — `alt+g` and `alt+q` are unbound there. diff --git a/internal/tui3/homeband_answer.go b/internal/tui3/homeband_answer.go index 906b56bb7f..6f94155343 100644 --- a/internal/tui3/homeband_answer.go +++ b/internal/tui3/homeband_answer.go @@ -117,13 +117,26 @@ func answerable(row session.SessionRow, now time.Time) (session.PresenceQuestion // answerSent is what this window already sent for one row's question, when it // sent one and the question is still the same question. func (a *app) answerSent(row session.SessionRow, question session.PresenceQuestion) (homeAnswered, bool) { - sent, ok := a.answered[strings.TrimSpace(row.Dir)] + sent, ok := a.answered[answerSlot(row.Dir, question)] if !ok || sent.kind != question.Kind || sent.id != question.ID { return homeAnswered{}, false } return sent, true } +// answerSlot is the key this window remembers one answer under: the session +// folder AND the question inside it. +// +// ONE SLOT PER QUESTION, NOT PER CONVERSATION. A conversation used to have at +// most one answerable row on this screen, so its folder was identity enough. +// `to check` gives one conversation a row per landing it is holding +// (homepanel_needs.go), and a single slot meant answering the second landing +// forgot the first — whose chips came back, and whose key could be pressed +// again, sending a second answer for a question already answered from here. +func answerSlot(dir string, question session.PresenceQuestion) string { + return strings.TrimSpace(dir) + "\x00" + string(question.Kind) + ":" + itoa64(question.ID) +} + // drawAnswerBand is the chips, or the line that says they have been pressed. // // A WINDOW THAT CANNOT ANSWER DRAWS NOTHING (the absence law): with no seam @@ -347,7 +360,7 @@ func (a *app) rememberAnswered(dir string, question session.PresenceQuestion, la delete(a.answered, at) } } - a.answered[dir] = homeAnswered{kind: question.Kind, id: question.ID, label: label, at: now} + a.answered[answerSlot(dir, question)] = homeAnswered{kind: question.Kind, id: question.ID, label: label, at: now} } // answeringHere reports that the row on home IS the conversation this window is diff --git a/internal/tui3/homecell.go b/internal/tui3/homecell.go index 3766d47f63..c863cd1537 100644 --- a/internal/tui3/homecell.go +++ b/internal/tui3/homecell.go @@ -112,6 +112,8 @@ func (a *app) homeLineRows(line homeLine, at, width int, pal palette, heading bo texts, head = []string{homeCellHead(cell, width, pal, heading)}, at case cellWhisper, cellFold: texts = []string{homeCellQuiet(cell, width, pal, lit)} + case cellGroup: + texts = []string{homeCellGroup(cell, width, pal)} case cellBar: texts = []string{homeCellBand(homeCellLeadBlank+homeSpendMeter(cell.share, width-homeGridLead, pal), width, pal, lit)} case cellSpark: @@ -200,6 +202,18 @@ func homeSparkCells(values []float64) []string { return out } +// homeCellGroup is a group's own line inside a panel: its word and count at the +// left and its clause at the right, both dim, under the rows' own lead +// ([homePanelGroup]). +// +// IT IS DIMMER THAN A HEADING ON PURPOSE. A panel's heading is the places' one +// heading ink and marks itself when the cursor is in it ([homeCellHead]); a +// group is a sorting of rows INSIDE one panel, and a second thing on the column +// wearing heading ink would read as a second panel. +func homeCellGroup(cell *homeCell, width int, pal palette) string { + return switcherSides(max(1, width), cell.title, cell.right, pal.dim, pal.dim) +} + // homeCellQuiet is a whisper or a fold: dim words under the rows' own lead. func homeCellQuiet(cell *homeCell, width int, pal palette, lit bool) string { text := homeCellLeadBlank + pal.dim(fit(cell.title, max(0, width-homeGridLead))) @@ -216,15 +230,18 @@ func homeCellBand(text string, width int, pal palette, lit bool) string { } // homeCellRow paints a row and the line under it. +// A ROW THAT GROWS DRAWS ITS SECOND LINE ONLY UNDER THE CURSOR, and the band +// covers both of them: the two lines are one row, and a ground that stopped +// half way would read as two ([homeCell.grows]). func (a *app) homeCellRow(line homeLine, at, width int, pal palette, lit bool) []string { cell := line.cell body := homeCellBody(a.homeCellDoor(cell, at, width-homeGridLead), width-homeGridLead, pal, lit) rows := []string{homeCellBand(a.homeCellLead(cell, at, pal)+body, width, pal, lit)} - if cell.sub != "" { - under := switcherSides(max(1, width-homeGridLead), cell.sub, a.homeRowAnswers(line), pal.dim, pal.muted) - rows = append(rows, homeCellLeadBlank+under) + if cell.sub == "" || (cell.grows && at != a.home.cursor) { + return rows } - return rows + under := switcherSides(max(1, width-homeGridLead), cell.sub, a.homeRowAnswers(line, at), pal.dim, pal.muted) + return append(rows, homeCellBand(homeCellLeadBlank+under, width, pal, lit && cell.grows)) } // homeCellLead is the row's mark and the air after it, or two blank cells. diff --git a/internal/tui3/homegrid.go b/internal/tui3/homegrid.go index b484a42197..701d6528bc 100644 --- a/internal/tui3/homegrid.go +++ b/internal/tui3/homegrid.go @@ -254,6 +254,13 @@ type homeGridInput struct { rows []switcherRow // ledger is the `since you left` lines the switcher reads. ledger []switcherRow + // calls is every landing whose check is the person's, as the `to check` + // group draws them, and older how many aged out of it. IT IS READ ONCE, + // HERE, because three readers need the same answer: the group's rows, the + // `since you left` line that steps aside for a landing already on the + // screen, and the pulse's count (homepanel_needs.go's [needsCallOf]). + calls []needsItem + callsOlder int // world is every project, the ones home knows only through a watch // included ([homeView.everyProject]). world session.World @@ -283,7 +290,9 @@ func (h *homeView) gridInput() homeGridInput { world.Projects = h.everyProject() here := switcherHere{session: h.here, coming: h.claim, hosted: h.far} reading := readSwitcher(world, h.items, h.fired, here, h.gone, h.seen, h.world.Read, h.ledger) - return homeGridInput{rows: reading.rows, ledger: reading.ledger, world: world, items: h.items, + calls, older := needsFresh(needsCalls(world, h.world.Read), h.world.Read) + return homeGridInput{rows: reading.rows, ledger: reading.ledger, calls: calls, callsOlder: older, + world: world, items: h.items, errands: h.switchExchanges(), bucket: h.bucket, launch: h.launch, tilde: h.tilde, last: h.last, repos: h.repos, spend: h.spend, seen: h.seen, now: h.world.Read} } @@ -304,11 +313,42 @@ type homePanelRows struct { // said is what the heading carries after its word — a count, an age — and // "" for the word alone. said string + // group is the run of rows at the FOOT of lines that stands under a dim line + // of its own and folds before any row above it, and nil for a panel whose + // rows are one list ([homePanelGroup]). + group *homePanelGroup // right is a clause the heading carries at its right margin, and money the // figure inside it drawn in the money ink — the day's spend on `spend`. right, money string } +// homePanelGroup is a SECOND HEADING INSIDE ONE PANEL: a run of the panel's own +// rows, drawn under a dim line of their own, which folds to nothing before the +// panel gives up a row above it. +// +// IT IS THE PANEL'S SHAPE SAID AGAIN ONE LEVEL DOWN, deliberately: a panel is a +// heading, its rows and a fold, and a group is a line, its rows and the same +// fold — so a squeeze needs no second rule. The rows sit at the FOOT of +// [homePanelRows.lines], so the ordinary bottom-up cut already takes them first, +// and when none of them is left on the screen the group's line goes and the +// panel's own fold names the group instead of saying `more` +// ([homeGridPanel.fold]). +// +// `needs you` is the only panel with one today: the landings a person has not +// checked, under the live questions that stopped a conversation +// (homepanel_needs.go). +type homePanelGroup struct { + // at is where the group's rows begin in [homePanelRows.lines]; everything + // before it is the panel's own. + at int + // word is the group's name — the left of its line, and the word the panel's + // fold uses instead of `more` while the group is shut. + word string + // said is the count after the word, and right the dim clause the line + // carries at its margin. + said, right string +} + // homeCellKind is which shape one line of a panel is drawn in. type homeCellKind uint8 @@ -319,6 +359,10 @@ const ( cellHead cellWhisper cellFold + // cellGroup is a group's own line inside a panel: its word and count dim at + // the left, its clause dim at the right ([homePanelGroup]). It is not a + // stop and it is never lit. + cellGroup // cellBar, cellSpark and cellFacts are the spend panel's three lines: the // day against its allowance, the fortnight, and who it went to and what for // (homepanel_spend.go). @@ -365,8 +409,19 @@ type homeCell struct { hold bool door string // sub is the line under the row, and subRight what that line carries at - // its right — the answers a digit sends. + // its right — the answers a key sends. sub, subRight string + // answers is the clause of chips this row WOULD draw at the right of its + // second line — each key beside its own word — and "" for a row with no + // answer to offer. Whether they are actually drawn is the frame's to say: + // exactly one row draws them ([app.homeAnswerAt]). + answers string + // grows says the row draws that line ONLY WHILE THE CURSOR IS ON IT, which + // is how a landing is one line at rest and two under the cursor + // (homepanel_needs.go). The line it grows into is reserved by its panel + // ([homeGridPanel.height]) so the column does not change shape as the + // cursor walks over the rows. + grows bool // share is how full the spend bar is, and spark the fortnight's days. share float64 spark []float64 @@ -384,8 +439,14 @@ type homeCell struct { } // height is how many screen rows one line takes. +// +// A ROW THAT GROWS UNDER THE CURSOR IS ONE ROW HERE. The line it grows into is +// the PANEL's reservation ([homeGridPanel.height]) rather than this row's +// height, because exactly one row of a frame is under the cursor and a layout +// that changed shape as the cursor walked over these rows would move every panel +// under them on every arrow key ([homeCell.grows]). func (l homeLine) height() int { - if l.cell != nil && l.cell.sub != "" { + if l.cell != nil && l.cell.sub != "" && !l.cell.grows { return 2 } return 1 @@ -447,12 +508,44 @@ func (p homeGridPanel) height() int { for _, line := range p.read.lines[:p.shown] { n += line.height() } + // A GROUP'S LINE AND THE AIR OVER IT ARE THE PANEL'S ROWS TOO, drawn only + // while the group has a row of its own left on the screen ([homeGridPanel.lines]). + if p.groupOpen() { + n++ + if p.read.group.at > 0 { + n++ + } + } + // AND ONE LINE IS KEPT FOR THE ROW THE CURSOR WILL GROW. Which row that is + // belongs to the paint; that one of them will grow is known here, and + // reserving it is what keeps the column the same height whichever row the + // cursor is standing on ([homeCell.grows]). + if p.growsARow() { + n++ + } if p.folds() { n++ } return n } +// groupOpen reports that the panel is drawing its group's own line, which it +// does while at least one of the group's rows is still shown. +func (p homeGridPanel) groupOpen() bool { + return p.read.group != nil && p.shown > p.read.group.at +} + +// growsARow reports that one of the rows on the screen will grow a line under +// the cursor. +func (p homeGridPanel) growsARow() bool { + for _, line := range p.read.lines[:p.shown] { + if line.cell != nil && line.cell.grows { + return true + } + } + return false +} + // homeColumnHeight is a column's height with one blank row between panels. func homeColumnHeight(column []*homeGridPanel) int { n, drawn := 0, 0 @@ -581,23 +674,18 @@ func growColumn(column []*homeGridPanel, room int) { } } -// shrink cuts a panel to its floor: the heading, as many whole rows as fit in -// what the floor leaves after the fold line, and the fold. A panel already -// inside its floor is left alone. +// shrink cuts a panel to its floor: the heading, as many whole rows as fit under +// it, and the fold. A panel already inside its floor is left alone. +// +// IT TAKES ROWS FROM THE BOTTOM, WHICH IS WHY A GROUP NEEDS NO RULE OF ITS OWN. +// A group's rows sit at the foot of the panel's list ([homePanelGroup]), so the +// walk down from the natural height spends them first and the group's line goes +// with the last of them — the `to check` landings fold before a `needs you` row +// is given up, without this function knowing that either exists. func (p *homeGridPanel) shrink() { - if p.height() <= p.slot.least { - return + for p.shown > 0 && p.height() > p.slot.least { + p.shown-- } - budget := p.slot.least - 2 - kept, used := 0, 0 - for _, line := range p.read.lines { - if used+line.height() > budget { - break - } - used += line.height() - kept++ - } - p.shown = kept } // homeGridLayout reads every panel and fits each column: the panels in table @@ -698,7 +786,20 @@ func (p homeGridPanel) lines() []homeLine { } return out } - out = append(out, p.read.lines[:p.shown]...) + if group := p.read.group; group != nil && p.groupOpen() { + out = append(out, p.read.lines[:group.at]...) + // A GROUP STANDS OFF THE ROWS ABOVE IT with the same blank row that + // stands between two panels, and takes none when it is the panel's + // first line. + if group.at > 0 { + out = append(out, homeLine{kind: homeBlank}) + } + out = append(out, homeLine{kind: homeSwitchHead, cell: &homeCell{kind: cellGroup, + panel: id, title: group.word + rowSep + group.said, right: group.right}}) + out = append(out, p.read.lines[group.at:p.shown]...) + } else { + out = append(out, p.read.lines[:p.shown]...) + } if p.folds() { out = append(out, p.fold()) } @@ -712,7 +813,7 @@ func (p homeGridPanel) lines() []homeLine { func (p homeGridPanel) fold() homeLine { var counts []string if n := p.hidden(); n > 0 { - counts = append(counts, groupedInt(n)+" more") + counts = append(counts, groupedInt(n)+" "+p.foldMoreWord()) } if n := p.read.older; n > 0 { counts = append(counts, groupedInt(n)+" "+homeFoldOlderWord) @@ -729,6 +830,22 @@ func (p homeGridPanel) fold() homeLine { return homeLine{kind: homeSwitchHead, cell: cell} } +// homeFoldMoreWord is what the fold calls the rows it is standing for when they +// are simply more of the panel's own. +const homeFoldMoreWord = "more" + +// foldMoreWord is what the fold calls the rows it is standing for: the GROUP's +// name where every one of them is the group's and the group's own line is not on +// the screen — `8 to check · tasks` is the whole of what a squeezed panel says +// about its landings — and `more` everywhere else. +func (p homeGridPanel) foldMoreWord() string { + group := p.read.group + if group == nil || p.groupOpen() || p.shown < group.at { + return homeFoldMoreWord + } + return group.word +} + // homeFoldKey is the identity a fold door carries beside its place word, so the // cursor on `3 more · tasks` is told apart from a `since you left` line that // opens the same place ([homeLine.sameRow]). It starts with a NUL, which no @@ -1035,47 +1152,94 @@ func switcherRowLine(row switcherRow, cell *homeCell) homeLine { dir: homeBucketOf(row.session.Transcript), cell: cell} } -// homeGridAnswer is a digit on the resting grid: THE TOP QUESTION, FROM -// ANYWHERE ON HOME, WITH NO CURSOR MOVE (law 7). The top question is the first -// row of `needs you` that draws its answers, so the key a person presses is one -// they can see on the screen; a digit with no such row falls through to the -// row under the cursor and then to the box, as it always did. +// homeGridAnswer is an answer key on the resting grid: THE ONE ANSWERING ROW OF +// THE FRAME, FROM ANYWHERE ON HOME (law 7). That row is the one drawing the +// chips, so the key a person presses is one they can see on the screen; a key +// with no such row falls through to the row under the cursor and then to the +// box, as it always did. func (a *app) homeGridAnswer(key string) (tea.Cmd, bool) { if !a.home.gridOn() { return nil, false } - for _, line := range a.home.lines { - if line.cell == nil || line.cell.panel != panelNeeds { - continue - } - if words := a.homeRowAnswers(line); words != "" && words != answerWaitingWord && words != needsOpenWord { - return a.answerRowKey(a.homeTrue(line.row), key) + at := a.homeAnswerAt() + if at < 0 { + return nil, false + } + line := a.home.lines[at] + if line.task != nil { + return a.homeAnswerLanding(line, key) + } + return a.answerRowKey(a.homeTrue(line.row), key) +} + +// homeAnswerAt is THE ONE ROW OF THE FRAME THAT DRAWS THE CHIPS AND TAKES THE +// KEY: the row under the cursor when it can take an answer, and the top row of +// `needs you` that can otherwise. It is -1 when nothing on the frame is +// answerable. +// +// THE CURSOR OUTRANKS THE TOP ROW BECAUSE A PERSON WHO WALKED SOMEWHERE MEANT +// IT. Law 7 put the chips on the top question so that a digit worked without +// moving the cursor, and that still holds for a frame nobody has walked; once +// somebody has walked onto a landing, `a` has to mean THAT landing or the key +// answers a row the person is not looking at. One function, so the chip and the +// key can never be two different rows. +func (a *app) homeAnswerAt() int { + if a.homeOffersAnswer(a.home.cursor) { + return a.home.cursor + } + for at := range a.home.lines { + if a.homeOffersAnswer(at) { + return at } } - return nil, false + return -1 } -// homeRowAnswers is what a `needs you` row draws at the right of its question: -// the answers, under the answer band's own four rules ([drawAnswerBand]) — the -// question is fresh and offered answers, this window has somewhere to leave -// one, it has not already sent one (the waiting word stands in for a moment -// after it has), and no question this window raised about the row is standing -// over it ([app.answersStepAside]). Every fact is in memory; nothing is read. +// homeOffersAnswer reports that the line at `at` is a row this window can +// actually send an answer for. +func (a *app) homeOffersAnswer(at int) bool { + if at < 0 || at >= len(a.home.lines) { + return false + } + words := a.homeRowOffer(a.home.lines[at]) + return words != "" && words != answerWaitingWord +} + +// homeRowOffer is the answers ONE row could take, under the answer band's own +// four rules ([drawAnswerBand]) — the question is fresh and offered answers, +// this window has somewhere to leave one, it has not already sent one (the +// waiting word stands in for a moment after it has), and no question this +// window raised about the row is standing over it ([app.answersStepAside]). +// Every fact is in memory; nothing is read. // -// A ROW THAT SAYS `enter` KEEPS SAYING IT. It is an instruction rather than an -// answer — the row is not the top question, or its question has a paragraph — -// and the gate is about answers (homepanel_needs.go). -func (a *app) homeRowAnswers(line homeLine) string { - if line.cell == nil || line.cell.subRight == "" { +// A LANDING IS THE SAME FOUR RULES WITH NOTHING ASKING. There is no live +// question to be fresh, so what is left is the doorstep: a conversation with a +// folder to leave the answer in can be answered from here, and one without +// cannot (homepanel_needs.go's [app.homeAnswerLanding]). +func (a *app) homeRowOffer(line homeLine) string { + if line.cell == nil || line.cell.panel != panelNeeds || line.cell.answers == "" { return "" } - if line.cell.subRight == needsOpenWord { - return needsOpenWord + row, now := a.homeTrue(line.row), a.home.world.Read + if line.task != nil { + question, ok := needsLandingQuestion(line) + if !ok { + return "" + } + if sent, ok := a.answerSent(row, question); ok { + if now.Sub(sent.at) < answerHoldFor { + return answerWaitingWord + } + return "" + } + if a.leaveAnswer == nil && !a.answeringHere(row) { + return "" + } + return line.cell.answers } if line.kind != homeSession { return "" } - row, now := a.homeTrue(line.row), a.home.world.Read question, ok := answerable(row, now) if !ok { return "" @@ -1089,6 +1253,26 @@ func (a *app) homeRowAnswers(line homeLine) string { if (a.leaveAnswer == nil && !a.answeringHere(row)) || a.answersStepAside(row) { return "" } + return line.cell.answers +} + +// homeRowAnswers is what the line under one row carries at its right: the +// answers where this is the frame's one answering row, the waiting word where +// this window has already answered it, and the row's own door word everywhere +// else. +// +// A ROW THAT SAYS `enter` KEEPS SAYING IT. It is an instruction rather than an +// answer — the row is not the answering row, or its question has a paragraph — +// and the gate is about answers (homepanel_needs.go). +func (a *app) homeRowAnswers(line homeLine, at int) string { + if line.cell == nil { + return "" + } + if words := a.homeRowOffer(line); words != "" { + if words == answerWaitingWord || at == a.homeAnswerAt() { + return words + } + } return line.cell.subRight } diff --git a/internal/tui3/homemachine.go b/internal/tui3/homemachine.go index 625c058c39..a0e9a2fa9b 100644 --- a/internal/tui3/homemachine.go +++ b/internal/tui3/homemachine.go @@ -94,7 +94,7 @@ func (f machineFacts) nearCeiling() bool { // read with it. It runs on a beat and never on a draw. func (a *app) readMachine(now time.Time, sessions []session.SessionRow, bands map[string][]StandingItemView) { a.readMachineMoney(now) - a.machine.hands, a.machine.wants = machineCounts(sessions, bands, a.exchanges) + a.machine.hands, a.machine.wants = machineCounts(now, sessions, bands, a.exchanges) } // readMachineMoney is the money half alone: what the day has cost, and the @@ -127,12 +127,19 @@ func (a *app) readMachineMoney(now time.Time) { // draws and these figures are about the MACHINE, so a count taken from the rows // on screen would fall the moment a ninth thing started — which is the opposite // of what the figure means. -func machineCounts(sessions []session.SessionRow, bands map[string][]StandingItemView, exchanges []*homeExchange) (hands, wants int) { +func machineCounts(now time.Time, sessions []session.SessionRow, bands map[string][]StandingItemView, exchanges []*homeExchange) (hands, wants int) { for _, row := range sessions { + // AND A LANDING NOBODY HAS CHECKED IS A WANT TOO. How many rows of + // `needs you` a conversation is — its own question, its landings, or + // both — is ONE reading and it is the panel's own + // (homepanel_needs.go's [needsWants]), so a pulse over a home cannot + // claim a number the rows under it do not show. It answers zero for an + // archived conversation and for one whose only question IS its landing, + // which is the row the panel drops. + wants += needsWants(row, now) switch { case row.Archived: case row.NeedsPerson(): - wants++ case row.Tasks.Running > 1: hands += row.Tasks.Running case row.Tasks.Running == 1 || row.Live && row.Presence.State == session.PresenceWorking: diff --git a/internal/tui3/homeneedsgroups_test.go b/internal/tui3/homeneedsgroups_test.go new file mode 100644 index 0000000000..92acc21e93 --- /dev/null +++ b/internal/tui3/homeneedsgroups_test.go @@ -0,0 +1,313 @@ +package tui3 + +import ( + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/aforge-v2/internal/session" +) + +// ── `needs you`, IN TWO GROUPS (#884, the spec of record §1) ──────────────── +// +// The panel holds the live questions that have stopped something, then — under +// a dim line of their own — the landings nobody has checked. These tests pin +// the rules that are easy to break from the other side of the column: what the +// group line is, which row takes a key, and what `since you left` may say about +// a landing this panel is already showing. + +// openAt is the live lab at a frame of a chosen size, which is what the squeeze +// needs. +func (l *liveLab) openAt(width, height int) *app { + l.t.Helper() + a := l.app(l.mine) + a.width, a.height = width, height + a.leaveAnswer = func(string, session.QuestionKind, uint64, string) error { return nil } + a.openHome() + homeText(a) + return a +} + +// archive puts one of the lab's conversations away, the way a person does. +func (l *liveLab) archive(id string) { + l.t.Helper() + dir := filepath.Join(l.project("-alpha"), id) + meta, err := session.LoadMeta(dir) + if err != nil { + l.t.Fatal(err) + } + meta.Archived = true + if err := session.SaveMeta(dir, meta); err != nil { + l.t.Fatal(err) + } +} + +// landed writes one landing whose call is the person's. +func (l *liveLab) landed(id, label string, ago time.Duration) { + l.t.Helper() + l.task("-alpha", session.TaskIndexEntry{ID: id, SessionID: "aaaa000000000002", Label: label, Title: label, + Status: string(session.TaskUnverified), EndedAt: l.now.Add(-ago)}) +} + +// THE GROUP LINE IS FURNITURE AND NOT A STOP: the cursor walks from the last +// question straight onto the first landing, and the blank over the group line +// is not a stop either. +func TestTheToCheckGroupLineIsNotACursorStop(t *testing.T) { + l := newLiveLab(t) + l.live("-beta", "bbbb000000000001", session.SessionPresence{State: session.PresenceWaiting, + Question: consentQuestionAt(7, "needs your ok to run bash", l.now.Add(-2*time.Hour))}) + l.landed("4", "fix the flaky sieve", 30*time.Minute) + a := l.open() + at := -1 + for i, line := range a.home.lines { + if line.cell != nil && line.cell.title == "Pricing Site" { + at = i + } + } + if at < 0 { + t.Fatal("the question is not a line of the column") + } + a.home.cursor = at + a.home.move(1) + line := a.home.lines[a.home.cursor] + if line.task == nil || line.task.ID != "4" { + t.Fatalf("the cursor did not step from the question onto the landing: %+v", line.cell) + } + for _, line := range a.home.lines { + if line.cell != nil && line.cell.kind == cellGroup && line.stop() { + t.Fatal("the group line is a cursor stop") + } + } +} + +// THE CHIPS ARE ON EXACTLY ONE ROW OF THE FRAME (law 7). Two questions and a +// landing, and only one answer clause is painted anywhere. +func TestTheAnswerChipsAreOnOneRowOfTheFrame(t *testing.T) { + l := newLiveLab(t) + l.live("-beta", "bbbb000000000001", session.SessionPresence{State: session.PresenceWaiting, + Question: consentQuestionAt(7, "needs your ok to run bash", l.now.Add(-2*time.Hour))}) + l.live("-alpha", "aaaa000000000002", session.SessionPresence{State: session.PresenceWaiting, + Question: consentQuestionAt(9, "needs your ok to run write", l.now.Add(-5*time.Minute))}) + l.landed("4", "fix the flaky sieve", 30*time.Minute) + a := l.open() + frame := homeText(a) + if got := strings.Count(frame, "1 allow once"); got != 1 { + t.Fatalf("the consent's chips are drawn on %d rows:\n%s", got, frame) + } + if got := strings.Count(frame, needsYesKey+" accept"); got != 0 { + t.Fatalf("a landing nobody is standing on drew its answers:\n%s", frame) + } + // AND THE CURSOR OUTRANKS THE TOP ROW. Walked onto the landing, the chips + // move with it and the question above says `enter` again. + for i, line := range a.home.lines { + if line.task != nil && line.task.ID == "4" { + a.home.cursor = i + } + } + frame = homeText(a) + if strings.Count(frame, "1 allow once") != 0 { + t.Fatalf("the chips stayed on the top question with the cursor on a landing:\n%s", frame) + } + if got := strings.Count(frame, needsYesKey+" accept"); got != 1 { + t.Fatalf("the landing under the cursor drew its answers %d times:\n%s", got, frame) + } +} + +// A DIGIT WITH THE CURSOR ON NOTHING ANSWERABLE STILL REACHES THE TOP QUESTION, +// which is law 7 and did not change. +func TestADigitStillReachesTheTopQuestionFromALandingThatCannotAnswer(t *testing.T) { + l := newLiveLab(t) + l.live("-beta", "bbbb000000000001", session.SessionPresence{State: session.PresenceWaiting, + Question: consentQuestionAt(7, "needs your ok to run bash", l.now.Add(-2*time.Hour))}) + a := l.open() + var left []string + a.leaveAnswer = func(dir string, kind session.QuestionKind, id uint64, key string) error { + left = append(left, string(kind)+"/"+key) + return nil + } + a.home.point(a.file) + homeText(a) + if _, took := a.homeGridAnswer("1"); !took { + t.Fatal("the digit did not reach the top question") + } + if len(left) != 1 || left[0] != string(session.QuestionConsent)+"/1" { + t.Fatalf("the digit went somewhere else: %v", left) + } +} + +// SAID ONCE ACROSS THE COLUMNS: `since you left` does not repeat a landing that +// `to check` is showing, and shows it again once the landing has aged out of the +// group. +func TestSinceYouLeftOmitsALandingToCheckIsShowing(t *testing.T) { + l := newLiveLab(t) + l.landed("4", "fix the flaky sieve", 30*time.Minute) + a := l.open() + a.home.seen = l.now.Add(-4 * time.Hour) + a.home.build() + frame := homeText(a) + if got := strings.Count(frame, "fix the flaky sieve"); got != 1 { + t.Fatalf("the landing is drawn on %d rows of the column:\n%s", got, frame) + } + if rows := panelRows(a, panelLeft); len(rows) != 0 { + t.Fatalf("since you left repeated the landing: %+v", rows) + } + // AND ONCE IT IS HISTORY IT IS AN ORDINARY LINE OF `since you left` AGAIN. + l2 := newLiveLab(t) + l2.landed("4", "fix the flaky sieve", homeNeedsTaskFresh+time.Hour) + b := l2.open() + b.home.seen = l2.now.Add(-homeNeedsTaskFresh - 2*time.Hour) + b.home.build() + found := false + for _, row := range panelRows(b, panelLeft) { + if strings.Contains(row.title, "fix the flaky sieve") { + found = true + } + } + if !found { + t.Fatalf("an aged-out landing never came back to since you left:\n%s", homeText(b)) + } +} + +// THE GROUP FOLDS BEFORE A `needs you` ROW GOES. At a hundred and twenty by +// fourteen the panel keeps its question and says the landings on its fold. +func TestToCheckFoldsBeforeANeedsYouRowGoes(t *testing.T) { + l := newLiveLab(t) + l.live("-beta", "bbbb000000000001", session.SessionPresence{State: session.PresenceWaiting, + Question: consentQuestionAt(7, "needs your ok to run bash", l.now.Add(-2*time.Hour))}) + for i, label := range []string{"one", "two", "three"} { + l.landed(itoa(i+1), "landed "+label, time.Duration(i+1)*time.Hour) + } + a := l.openAt(120, 14) + frame := homeText(a) + if !strings.Contains(frame, "Pricing Site") { + t.Fatalf("the question gave way before the landings did:\n%s", frame) + } + if strings.Contains(frame, needsCheckClause) { + t.Fatalf("the group line survived the squeeze:\n%s", frame) + } + if !strings.Contains(frame, "3 "+needsCheckWord+" · tasks") { + t.Fatalf("the fold does not say what the folded group is:\n%s", frame) + } +} + +// THE PULSE'S COUNT IS THE SUM OF THE TWO GROUPS, read through the panel's own +// reading of a conversation's rows rather than through a second one +// ([needsWants]). +// +// THE LANDINGS' OWN CONVERSATION IS LIVE AND WAITING ON THEM HERE, which is the +// shape that made the first cut of this count disagree with the rows: the +// conversation contributed a want the panel does not draw a row for. +func TestThePulseCountsBothGroupsOfNeedsYou(t *testing.T) { + l := newLiveLab(t) + l.live("-beta", "bbbb000000000001", session.SessionPresence{State: session.PresenceWaiting, + Question: consentQuestionAt(7, "needs your ok to run bash", l.now.Add(-2*time.Hour))}) + l.live("-alpha", "aaaa000000000002", session.SessionPresence{State: session.PresenceWaiting, + Reason: needsYourCallLead + "fix the flaky sieve"}) + l.landed("4", "fix the flaky sieve", 30*time.Minute) + l.landed("5", "port the parser", 5*time.Hour) + // AND A LANDING THAT AGED OUT IS NOT COUNTED, because it is not a row. + l.landed("6", "old business", homeNeedsTaskFresh+time.Hour) + a := l.open() + if rows := panelRows(a, panelNeeds); len(rows) != 3 || a.machine.wants != len(rows) { + t.Fatalf("the pulse says %d want you over %d rows: %+v", a.machine.wants, len(rows), rows) + } +} + +// A CONVERSATION STOPPED AT ITS FUEL GATE KEEPS ITS ROW, landing or no landing. +// It banks no card at the desk and so writes no question object, and the first +// cut of the "said once" test read that absence as "this is only waiting on its +// landing" — which took a paused run off home entirely. +func TestAPausedRunKeepsItsRowBesideItsOwnLanding(t *testing.T) { + l := newLiveLab(t) + l.live("-alpha", "aaaa000000000002", session.SessionPresence{State: session.PresenceWaiting, + Reason: "out of fuel · 12 of 12 spent"}) + l.landed("4", "fix the flaky sieve", 30*time.Minute) + a := l.open() + rows := panelRows(a, panelNeeds) + if len(rows) != 2 { + t.Fatalf("the paused run lost its row beside its landing: %+v", rows) + } + frame := homeText(a) + if !strings.Contains(frame, "out of fuel") || !strings.Contains(frame, "fix the flaky sieve") { + t.Fatalf("the frame does not hold both:\n%s", frame) + } + if a.machine.wants != len(rows) { + t.Fatalf("the pulse says %d want you over %d rows", a.machine.wants, len(rows)) + } +} + +// WORK IN A CONVERSATION SOMEBODY PUT AWAY IS NOT WAITING ON THEM: no row on +// `to check`, and nothing in the count. +func TestAnArchivedConversationsLandingIsNotOnToCheck(t *testing.T) { + l := newLiveLab(t) + l.landed("4", "fix the flaky sieve", 30*time.Minute) + l.archive("aaaa000000000002") + a := l.open() + if rows := panelRows(a, panelNeeds); len(rows) != 0 { + t.Fatalf("an archived conversation's landing is on to check: %+v", rows) + } + if a.machine.wants != 0 { + t.Fatalf("the pulse counts an archived conversation's landing: %d", a.machine.wants) + } +} + +// ONE ANSWER PER LANDING, AND A CONVERSATION MAY HOLD SEVERAL. This window +// remembers what it sent per QUESTION; a single slot per conversation meant +// answering the second landing forgot the first, whose chips came back and whose +// key sent a second answer for a question already answered from here. +func TestAnsweringASecondLandingDoesNotReopenTheFirst(t *testing.T) { + l := newLiveLab(t) + l.landed("4", "fix the flaky sieve", 30*time.Minute) + l.landed("5", "port the parser", 5*time.Hour) + a := l.open() + var left []string + a.leaveAnswer = func(dir string, kind session.QuestionKind, id uint64, key string) error { + left = append(left, itoa64(id)+"/"+key) + return nil + } + press := func(id string) { + for i, line := range a.home.lines { + if line.task != nil && line.task.ID == id { + a.home.cursor = i + } + } + homeText(a) + a.homeGridAnswer(needsYesKey) + } + press("4") + press("5") + press("4") + want := []string{"4/" + session.LandingYesKey, "5/" + session.LandingYesKey} + if len(left) != len(want) || left[0] != want[0] || left[1] != want[1] { + t.Fatalf("a landing was answered twice: %v", left) + } +} + +// SAID ONCE INSIDE THE PANEL TOO. A live conversation waiting on nothing but its +// own landing writes `waiting on you · your call on <title>` with no question +// object ([session.Agent.waitingOnPerson] reads a pending decision last), and +// drawing it as a question put the same piece of work on two rows of one panel. +// The landing's row is the one that survives — it has the work's own name, its +// files and its two answers. +func TestAConversationWaitingOnItsOwnLandingIsOnlyTheLandingsRow(t *testing.T) { + l := newLiveLab(t) + l.live("-alpha", "aaaa000000000002", session.SessionPresence{State: session.PresenceWaiting, + Reason: "your call on fix the flaky sieve"}) + l.landed("4", "fix the flaky sieve", 30*time.Minute) + a := l.open() + rows := panelRows(a, panelNeeds) + if len(rows) != 1 || rows[0].title != "fix the flaky sieve" { + t.Fatalf("the landing is on the panel twice, or not at all: %+v", rows) + } + // AND A CONVERSATION WITH A QUESTION OF ITS OWN KEEPS ITS ROW, landing or no + // landing: the question is a different thing, and it blocks. + l2 := newLiveLab(t) + l2.live("-alpha", "aaaa000000000002", session.SessionPresence{State: session.PresenceWaiting, + Question: consentQuestionAt(7, "needs your ok to run bash", l2.now.Add(-time.Minute))}) + l2.landed("4", "fix the flaky sieve", 30*time.Minute) + b := l2.open() + if rows := panelRows(b, panelNeeds); len(rows) != 2 { + t.Fatalf("a conversation with a question of its own lost its row: %+v", rows) + } +} diff --git a/internal/tui3/homepanel_left.go b/internal/tui3/homepanel_left.go index 3da381cec9..eb288bb863 100644 --- a/internal/tui3/homepanel_left.go +++ b/internal/tui3/homepanel_left.go @@ -22,9 +22,19 @@ import ( // own ledger rows ([switcherReading.addLedger]), each carrying its door. type leftPanel struct{ homePanelBase } +// SAID ONCE ACROSS THE COLUMNS. A landing whose check is still the person's is +// drawn by `needs you`'s `to check` group, one column over and higher up the +// page ([needsChecking]); this panel drew it a second time, as an ordinary thing +// that happened while nobody was looking. It comes back here the moment it stops +// being a question — answered, or aged out of the group — because then it IS +// just something that happened. func (leftPanel) rows(in *homeGridInput) homePanelRows { + checking := needsChecking(in) lines := make([]homeLine, 0, len(in.ledger)) for _, row := range in.ledger { + if row.task != nil && checking[taskLedgerKey(*row.task)] { + continue + } lines = append(lines, leftLine(row)) } out := homePanelCut(panelLeft, lines) @@ -43,12 +53,22 @@ func leftLine(row switcherRow) homeLine { view: row.item, item: row.item.Item, cell: cell} } +// taskLedgerKey is ONE piece of work's identity across home's columns: the +// conversation that ran it and the node's number, which is the only pair that +// is unique ([session.TaskIndexEntry.ID] repeats across sessions). It is spelled +// here because three readers ask it — this panel's own key, the `to check` +// group's set of what it is already drawing, and the comparison between them +// (homepanel_needs.go's [needsChecking]). +func taskLedgerKey(task session.TaskIndexEntry) string { + return task.SessionID + "/" + task.ID +} + // leftKey is a ledger line's identity beside its place word ([homeLine.sameRow]): // the task, the file, or — for a firing or memory's line — its words. func leftKey(row switcherRow) string { switch { case row.task != nil: - return row.task.SessionID + "/" + row.task.ID + return taskLedgerKey(*row.task) case row.path != "": return row.path } diff --git a/internal/tui3/homepanel_live_test.go b/internal/tui3/homepanel_live_test.go index 0ed1fad1bf..525fdbc273 100644 --- a/internal/tui3/homepanel_live_test.go +++ b/internal/tui3/homepanel_live_test.go @@ -120,20 +120,28 @@ func TestNeedsYouOrdersTheWaitsAndDrawsAnswersOnTheTopRowOnly(t *testing.T) { } } -// A TASK THE RECORD MARKS AS YOUR CALL IS A ROW OF ITS OWN, and enter aims at -// the task rather than at the conversation's live edge. Work nobody could check -// says what the person can do about it, and draws no second `enter` beside that. +// A TASK THE RECORD MARKS AS YOUR CALL IS A ROW OF THE `to check` GROUP, one +// line of its own, under a group line that says what the group is. enter aims at +// the task rather than at the conversation's live edge. func TestNeedsYouCarriesATaskWaitingOnYourCall(t *testing.T) { l := newLiveLab(t) l.task("-alpha", session.TaskIndexEntry{ID: "4", SessionID: "aaaa000000000002", Label: "fix the flaky sieve", - Title: "fix the flaky sieve", Status: string(session.TaskUnverified), EndedAt: l.now.Add(-30 * time.Minute)}) + Title: "fix the flaky sieve", Status: string(session.TaskUnverified), EndedAt: l.now.Add(-30 * time.Minute), + FilesChanged: 3}) a := l.open() rows := panelRows(a, panelNeeds) - if len(rows) != 1 || rows[0].title != "fix the flaky sieve" || rows[0].sub != needsUncheckedWord || rows[0].subRight != "" { - t.Fatalf("the task's call is not a row of needs you that says what to do: %+v", rows) + if len(rows) != 1 || rows[0].title != "fix the flaky sieve" || rows[0].right != "3 files · 30m" { + t.Fatalf("the task's call is not a one-line row of needs you: %+v", rows) + } + if rows[0].mark != cellMarkNone { + t.Fatalf("a landing wears a mark: %+v", rows[0]) + } + frame := homeText(a) + if !strings.Contains(frame, needsCheckWord+" · 1") || !strings.Contains(frame, needsCheckClause) { + t.Fatalf("the group line does not name the group and say what it is:\n%s", frame) } - if frame := homeText(a); !strings.Contains(frame, "landed unchecked · enter to look") || strings.Contains(frame, "nobody could check it") { - t.Fatalf("the row under the call does not say what to do:\n%s", frame) + if strings.Contains(frame, "landed unchecked") { + t.Fatalf("the retired sub-line is still drawn:\n%s", frame) } for _, line := range panelLines(a, panelNeeds) { if line.cell.kind == cellRow && (line.task == nil || line.task.ID != "4") { @@ -142,6 +150,125 @@ func TestNeedsYouCarriesATaskWaitingOnYourCall(t *testing.T) { } } +// THE GROUP LINE IS DRAWN ONLY WHERE THE GROUP HAS ROWS, and it is not a stop: +// the cursor walks from the last question straight onto the first landing. +func TestToCheckDrawsNoGroupLineWithoutLandings(t *testing.T) { + l := newLiveLab(t) + l.live("-beta", "bbbb000000000001", session.SessionPresence{State: session.PresenceWaiting, + Question: consentQuestionAt(7, "needs your ok to run bash", l.now.Add(-2*time.Hour))}) + a := l.open() + if frame := homeText(a); strings.Contains(frame, needsCheckClause) { + t.Fatalf("a group with no rows drew its line:\n%s", frame) + } + for _, line := range panelLines(a, panelNeeds) { + if line.cell.kind == cellGroup { + t.Fatalf("a group line was built with no rows under it") + } + } +} + +// BLOCKING FIRST, HOWEVER OLD THE LANDING IS: a consent asked a minute ago sits +// above a landing from a week back, and the landing is under the group line. +func TestNeedsBlockingRowsSortFirst(t *testing.T) { + l := newLiveLab(t) + l.live("-beta", "bbbb000000000001", session.SessionPresence{State: session.PresenceWaiting, + Question: consentQuestionAt(7, "needs your ok to run bash", l.now.Add(-time.Minute))}) + l.task("-alpha", session.TaskIndexEntry{ID: "4", SessionID: "aaaa000000000002", Label: "fix the flaky sieve", + Title: "fix the flaky sieve", Status: string(session.TaskUnverified), EndedAt: l.now.Add(-40 * time.Hour)}) + a := l.open() + rows := panelRows(a, panelNeeds) + if len(rows) != 2 || rows[0].title != "Pricing Site" || rows[1].title != "fix the flaky sieve" { + t.Fatalf("the week-old landing did not sort under the fresh question: %+v", rows) + } + if rows[0].mark != cellMarkNeeds || rows[1].mark != cellMarkNone { + t.Fatalf("the mark is not on the stopped row alone: %+v", rows) + } + kinds := []homeCellKind{} + for _, line := range panelLines(a, panelNeeds) { + kinds = append(kinds, line.cell.kind) + } + if len(kinds) < 4 || kinds[0] != cellHead || kinds[1] != cellRow || kinds[len(kinds)-2] != cellGroup { + t.Fatalf("the group line does not stand between the question and the landing: %v", kinds) + } +} + +// THE LANDINGS ARE NEWEST FIRST, which is the opposite of the questions above +// them and is said in [needsPanel.rows]. +func TestToCheckDrawsTheNewestLandingFirst(t *testing.T) { + l := newLiveLab(t) + for i, ago := range []time.Duration{30 * time.Minute, 5 * time.Hour} { + id := itoa(i + 1) + l.task("-alpha", session.TaskIndexEntry{ID: id, SessionID: "aaaa000000000002", Label: "call " + id, + Title: "call " + id, Status: string(session.TaskUnverified), EndedAt: l.now.Add(-ago)}) + } + a := l.open() + if rows := panelRows(a, panelNeeds); len(rows) != 2 || rows[0].title != "call 1" || rows[1].title != "call 2" { + t.Fatalf("to check is not newest first: %+v", rows) + } +} + +// ONE LINE AT REST AND TWO UNDER THE CURSOR: the landing grows the report's +// first sentence and the task's own two answers, and the words are the ask's. +func TestALandingGrowsItsReportAndAnswersUnderTheCursor(t *testing.T) { + l := newLiveLab(t) + l.task("-alpha", session.TaskIndexEntry{ID: "4", SessionID: "aaaa000000000002", Label: "fix the flaky sieve", + Title: "fix the flaky sieve", Status: string(session.TaskUnverified), EndedAt: l.now.Add(-30 * time.Minute), + Outcome: "Reseeded the generator and the sieve is stable over a thousand runs."}) + a := l.open() + at := -1 + for i, line := range a.home.lines { + if line.task != nil && line.task.ID == "4" { + at = i + } + } + if at < 0 { + t.Fatal("the landing is not a line of the column") + } + if frame := homeText(a); strings.Contains(frame, "Reseeded the generator") { + t.Fatalf("the landing grew its line with the cursor elsewhere:\n%s", frame) + } + a.home.cursor = at + frame := homeText(a) + under := homeLineAfter(frame, "fix the flaky sieve") + if !strings.Contains(under, "Reseeded the generator") { + t.Fatalf("the cursor row did not grow the report's first sentence:\n%s", frame) + } + if !strings.Contains(under, needsYesKey+" accept") || !strings.Contains(under, needsNoKey+" not right") { + t.Fatalf("the grown line does not carry the ask's own answers:\n%s", frame) + } +} + +// AND THE KEY THE GROWN LINE DRAWS IS THE KEY THAT ANSWERS IT, through the one +// door a landing is answered by anywhere ([app.homeAnswerLanding]). +func TestALandingUnderTheCursorTakesItsOwnAnswerKey(t *testing.T) { + l := newLiveLab(t) + l.task("-alpha", session.TaskIndexEntry{ID: "4", SessionID: "aaaa000000000002", Label: "fix the flaky sieve", + Title: "fix the flaky sieve", Status: string(session.TaskUnverified), EndedAt: l.now.Add(-30 * time.Minute)}) + a := l.open() + var left []string + a.leaveAnswer = func(dir string, kind session.QuestionKind, id uint64, key string) error { + left = append(left, string(kind)+"/"+itoa64(id)+"/"+key) + return nil + } + for i, line := range a.home.lines { + if line.task != nil && line.task.ID == "4" { + a.home.cursor = i + } + } + homeText(a) + // AND A BARE LETTER STILL TYPES, which is why the key on this screen is a + // digit: `a` is the landing's key everywhere else and must not be one here. + if _, took := a.homeGridAnswer(session.LandingYesKey); took { + t.Fatal("a bare letter answered a landing from home") + } + if _, took := a.homeGridAnswer(needsYesKey); !took { + t.Fatal("the landing under the cursor did not take the accept it draws") + } + if len(left) != 1 || left[0] != string(session.QuestionLanding)+"/4/"+session.LandingYesKey { + t.Fatalf("the accept did not reach the conversation's doorstep as a landing answer: %v", left) + } +} + // A YOUR-CALL OLDER THAN TWO DAYS IS HISTORY, NOT A QUESTION: two fresh calls // are the panel's rows and its count, and the three that landed days ago are // one door into tasks under them. @@ -153,12 +280,18 @@ func TestNeedsYouAgesOldCallsOntoTheFold(t *testing.T) { Status: string(session.TaskUnverified), EndedAt: l.now.Add(-ago)}) } a := l.open() - if rows := panelRows(a, panelNeeds); len(rows) != 2 || rows[0].title != "call 2" || rows[1].title != "call 1" { + if rows := panelRows(a, panelNeeds); len(rows) != 2 || rows[0].title != "call 1" || rows[1].title != "call 2" { t.Fatalf("needs you is not the two fresh calls: %+v", rows) } frame := homeText(a) - if !strings.Contains(frame, "needs you · 2") || !strings.Contains(frame, "3 older · tasks") { - t.Fatalf("the heading does not count what is listed, or the fold does not count what aged:\n%s", frame) + // AND THE HEADING COUNTS THE QUESTIONS, NOT THE LANDINGS. With nothing + // stopped it draws its word alone (the emptiness law); the landings are + // counted on the group's own line and the aged ones on the fold. + if !strings.Contains(frame, "needs you") || strings.Contains(frame, "needs you · ") { + t.Fatalf("the heading counted the landings:\n%s", frame) + } + if !strings.Contains(frame, needsCheckWord+" · 2") || !strings.Contains(frame, "3 older · tasks") { + t.Fatalf("the group does not count what is listed, or the fold what aged:\n%s", frame) } } diff --git a/internal/tui3/homepanel_needs.go b/internal/tui3/homepanel_needs.go index bb79db6705..3585cda8e5 100644 --- a/internal/tui3/homepanel_needs.go +++ b/internal/tui3/homepanel_needs.go @@ -6,30 +6,52 @@ import ( "strings" "time" + tea "charm.land/bubbletea/v2" + "github.com/Agent-Field/aforge-v2/internal/session" ) // needsPanel is `needs you` (docs/design/home-mission-control/DESIGN.md §1, §3 -// P1): every question on the machine that is waiting on a person, the longest -// wait first (homeattention.go's [attentionOlder]). -// -// THREE SOURCES AND ONE LIST. A conversation stopped on a question, a task the -// record marks as the person's call, and a watch that needs somebody are three -// different engines asking the same thing, and a person answering them does not -// care which engine it was. Each row is two lines: the title with its age at the -// right, and under it the question's own sentence. -// -// THE ANSWERS ARE DRAWN ON THE TOP ANSWERABLE ROW AND NOWHERE ELSE (law 7). A -// digit pressed anywhere on home answers that row ([app.homeGridAnswer]), so the -// chips beside it are the key a person is about to press. Chips on a second row -// would be a `1` on screen twice with one meaning, which is a guess; every other -// row says `enter`, and enter opens it. +// P1): everything on the machine that is waiting on a person, in TWO GROUPS on +// one panel. +// +// BLOCKING FIRST, THEN WHAT HAS LANDED (the spec of record for #884). The top +// group is the live questions — a conversation stopped on a question or on a +// consent card, a watch that needs somebody — longest wait first +// ([attentionOlder]), each wearing the amber mark and carrying its own sentence. +// Under them the `to check` group is every task whose call is the person's, +// newest first, one line each. A live question is always above a landing however +// old the landing is, because a landing costs nothing while it waits and a +// stopped conversation costs everything. +// +// THE DIFFERENCE BETWEEN THE TWO GROUPS IS SAID ONCE, ON THE GROUP'S OWN LINE: +// `to check · 8` at the left and `finished, nobody has checked it` at the right +// (placeprose.go's [needsCheckWord] and [needsCheckClause]). It used to be said +// under every landing row, which was the same nine words nine times and pushed +// `where you were` off a forty-row frame (owner, 2026-09-11). +// +// A LANDING IS ONE LINE AT REST AND TWO UNDER THE CURSOR. The second line is the +// first sentence of what the work came to and the two answers the task itself +// offers ([session.TaskAsk]), which is the whole of what "check it" means; the +// panel reserves the line it grows into ([homeCell.grows]) so the column does +// not move as the cursor walks. +// +// THE ANSWERS ARE DRAWN ON ONE ROW OF THE FRAME AND NOWHERE ELSE (law 7): the +// row under the cursor when it can take an answer, and the top row that can +// otherwise ([app.homeAnswerAt]). Drawing and routing ask that one function, so +// a `1` on the screen and the key a person presses cannot be two different rows. type needsPanel struct{ homePanelBase } const ( - // needsOpenWord is what a row says where its answers are not drawn: a - // question with a paragraph, a task's call, a watch's question, any row - // below the top one. enter opens it. + // needsOpenWord is what a QUESTION row says where its answers are not drawn: + // one with a paragraph, or one that is not the frame's answering row. enter + // opens it. + // + // A LANDING NEVER SAYS IT. Its second line only exists under the cursor, and + // there the answers are the thing the line is for; a `to check` row that this + // window cannot answer draws the report's sentence and nothing at its right, + // which is the emptiness law rather than an instruction repeated on every + // row. enter still opens the record ([app.homeLandOnTask]). needsOpenWord = "enter" // needsAnswersCap is how many of a question's answers fit on its row. A // question with more draws the first ones and then [needsOpenWord], because @@ -37,37 +59,42 @@ const ( needsAnswersCap = 3 ) -// needsItem is one row before the panel orders it: when it was asked, the line, -// and the answers it would draw if it were the top answerable row. +// needsItem is one row before the panel orders it: when it was asked, and the +// line. type needsItem struct { - asked time.Time - line homeLine - answers string + asked time.Time + line homeLine } func (needsPanel) rows(in *homeGridInput) homePanelRows { - calls, older := needsFresh(needsCalls(in), in.now) - items := append(needsAsked(in), calls...) - sort.SliceStable(items, func(i, j int) bool { return attentionOlder(items[i].asked, items[j].asked) }) - lines := make([]homeLine, 0, len(items)) - drawn := false - for _, item := range items { - // A line that already names its door draws no second one ([needsUncheckedWord]). - if item.line.cell.sub != needsUncheckedWord { - item.line.cell.subRight = needsOpenWord - } - if item.answers != "" && !drawn { - item.line.cell.subRight, drawn = item.answers, true - } + asked := needsAsked(in) + sort.SliceStable(asked, func(i, j int) bool { return attentionOlder(asked[i].asked, asked[j].asked) }) + // AND THE LANDINGS ARE NEWEST FIRST, which is the opposite order and the + // right one for them: `needs you` is ranked by how long something has been + // stopped, and nothing is stopped here — the freshest landing is the work + // still in the person's head, and the oldest ages out of the group entirely + // ([needsFresh]). + calls := append([]needsItem(nil), in.calls...) + sort.SliceStable(calls, func(i, j int) bool { return attentionOlder(calls[j].asked, calls[i].asked) }) + lines := make([]homeLine, 0, len(asked)+len(calls)) + for _, item := range append(asked, calls...) { lines = append(lines, item.line) } out := homePanelCut(panelNeeds, lines) - out.said = countWord(len(lines)) - out.older = older + // THE HEADING COUNTS THE QUESTIONS AND THE GROUP LINE COUNTS THE LANDINGS. + // A frame holding only landings draws `needs you` with no count at all + // rather than `needs you · 0`, which is the emptiness law applied to a + // heading ([countWord]). + out.said = countWord(len(asked)) + out.older = in.callsOlder + if len(calls) > 0 { + out.group = &homePanelGroup{at: len(asked), word: needsCheckWord, + said: countWord(len(calls)), right: needsCheckClause} + } return out } -// needsFresh is the task calls that are still a question, and how many are +// needsFresh is the task calls that are still worth checking, and how many are // history. // // A YOUR-CALL OLDER THAN [homeNeedsTaskFresh] IS HISTORY, NOT A QUESTION (owner, @@ -80,7 +107,7 @@ func (needsPanel) rows(in *homeGridInput) homePanelRows { // not known to be old, and stays. func needsFresh(items []needsItem, now time.Time) (fresh []needsItem, older int) { for _, item := range items { - if !item.asked.IsZero() && now.Sub(item.asked) > homeNeedsTaskFresh { + if needsAged(item.asked, now) { older++ continue } @@ -89,15 +116,31 @@ func needsFresh(items []needsItem, now time.Time) (fresh []needsItem, older int) return fresh, older } +// needsAged is that rule as one predicate, so the panel's rows and the pulse's +// count age a landing out at the same instant ([machineCounts]). +func needsAged(asked, now time.Time) bool { + return !asked.IsZero() && now.Sub(asked) > homeNeedsTaskFresh +} + // needsAsked is the conversations and watches the switcher already ranks as // waiting on somebody. +// +// A CONVERSATION WAITING ONLY ON ITS OWN LANDING IS NOT ONE OF THEM — THE +// LANDING IS. [session.Agent.waitingOnPerson] reads a pending decision LAST and +// writes the sentence `your call on <title>` into the presence with no question +// object, so such a session drew as a question ABOVE the `to check` row for the +// very same piece of work: once under the CONVERSATION's name with a bare +// `enter`, once under the TASK's name with its files and its two answers. The +// second row is the better one and this drops the first (the spec of record's +// "said once", applied inside the panel as well as across the columns). func needsAsked(in *homeGridInput) []needsItem { var items []needsItem for _, row := range in.rows { - if !row.needs { + if !row.needs || (row.kind == switcherConversation && + needsLandingsSpeakFor(row.session, needsCallTitlesOn(in, row.session.ID))) { continue } - cell := &homeCell{panel: panelNeeds, mark: cellMarkNeeds, title: row.title} + cell := &homeCell{panel: panelNeeds, mark: cellMarkNeeds, title: row.title, subRight: needsOpenWord} homeLiveMargin(cell, row, sinceAt(row.at, in.now)) item := needsItem{asked: row.at} switch row.kind { @@ -105,7 +148,7 @@ func needsAsked(in *homeGridInput) []needsItem { head, whole := needsSentence(row.session) cell.sub = head if _, ok := answerable(row.session, in.now); ok && whole { - item.answers = answersWord(row.session.Presence.Question) + cell.answers = answersWord(row.session.Presence.Question) } case switcherStanding: cell.sub = switcherFirstLine(row.item.Item.NeedsPerson) @@ -116,6 +159,89 @@ func needsAsked(in *homeGridInput) []needsItem { return items } +// needsYourCallLead opens the sentence a session writes into its presence while +// nothing but a landed task is waiting on somebody — internal/session's +// `yourCallLine`, which is the tier's own word and a preposition +// ([tierYourCallWord]; taskpresence.go builds it the same way from the same +// word). It is rebuilt here rather than imported because the engine keeps it +// unexported; the seam is noted in the change entry. +const needsYourCallLead = tierYourCallWord + " on " + +// needsLandingsSpeakFor reports that the WHOLE of what a conversation is waiting +// on is one of its own landings, which the `to check` group is already drawing +// under the work's own name. +// +// IT IS THE ENGINE'S SENTENCE AND NOT AN ABSENCE. The first cut of this test was +// "the presence carries no question object", and that is wrong in two lanes that +// bank no card at the desk and say so: a sub-harness offer (`wants to run …`) +// and an adaptive run at its fuel gate (`out of fuel · …`) both write a reason +// with no question, and a conversation stopped at either of those would have +// VANISHED from home the moment it happened to own a landing +// (internal/session's taskpresence.go says which lanes bank nothing). So the +// test is the sentence the pending-decision arm writes, opened by +// [needsYourCallLead] and closed by the title of one of this conversation's own +// landings — a question of any other kind keeps its row. +func needsLandingsSpeakFor(row session.SessionRow, titles []string) bool { + if len(titles) == 0 || row.Presence.Question.Kind != "" { + return false + } + reason := strings.TrimSpace(row.Reason()) + if !strings.HasPrefix(reason, needsYourCallLead) { + return false + } + for _, title := range titles { + if title != "" && strings.HasSuffix(reason, title) { + return true + } + } + return false +} + +// needsCallTitlesOn is the titles the `to check` group is drawing for one +// conversation, read out of the reading the beat already took. +func needsCallTitlesOn(in *homeGridInput, id string) []string { + var titles []string + for _, call := range in.calls { + if call.line.task != nil && call.line.task.SessionID == id { + titles = append(titles, strings.TrimSpace(call.line.task.Title)) + } + } + return titles +} + +// needsWants is how many rows of `needs you` ONE conversation is: one per +// landing of its own that the `to check` group draws, and its own question where +// the panel keeps that row. +// +// THE PANEL BUILDS ITS ROWS FROM THESE READINGS AND THE PULSE COUNTS THEM FROM +// THIS ONE, so `N want you` over a home and the rows under it are the same +// arithmetic ([machineCounts]). +func needsWants(row session.SessionRow, now time.Time) int { + titles := needsCallTitles(row, now) + n := len(titles) + if row.NeedsPerson() && !needsLandingsSpeakFor(row, titles) { + n++ + } + return n +} + +// needsCallTitles is one conversation's landings as the `to check` group would +// draw them, by title, taken straight off the index row. +func needsCallTitles(row session.SessionRow, now time.Time) []string { + if row.Archived { + return nil + } + var titles []string + for i := range row.Tasks.Rows { + entry := row.Tasks.Rows[i] + if _, ok := needsCallOf(row, entry); !ok || needsAged(needsCallAt(entry), now) { + continue + } + titles = append(titles, strings.TrimSpace(entry.Title)) + } + return titles +} + // needsCalls is every task the project's record marks as the person's call — // work that finished and nobody could check, a landing that turned the work // back, a branch that would not fasten. They are read off the world's own index @@ -123,48 +249,187 @@ func needsAsked(in *homeGridInput) []needsItem { // the tasks place can never disagree about which work is waiting. // // ENTER OPENS THE CONVERSATION ON THE TASK'S RECORD ([app.homeLandOnTask]), -// which is where the question and its two answers are. -func needsCalls(in *homeGridInput) []needsItem { +// which is where the question and the whole of its evidence are. +func needsCalls(world session.World, now time.Time) []needsItem { var items []needsItem - for _, project := range in.world.Projects { + for _, project := range world.Projects { for _, row := range project.Sessions { for i := range row.Tasks.Rows { entry := row.Tasks.Rows[i] - status := taskEntryStatus(entry, row.Runs(entry)) - if !status.Attention || status.On != session.TaskWaitPerson || needsAskedInChat(row, entry) { + status, ok := needsCallOf(row, entry) + if !ok { continue } - items = append(items, needsCall(project, row, entry, status, in.now)) + items = append(items, needsCall(project, row, entry, status, now)) } } } return items } -// needsCall is one of those rows. +// needsCallOf is THE ONE READING OF "THIS LANDING IS WAITING ON THE PERSON", +// and its status. +// +// THREE READERS AND ONE ANSWER. The `to check` group draws these rows, `since +// you left` drops the landings this group is already drawing ([leftPanel.rows]), +// and the pulse counts them beside the questions ([machineCounts]). A second +// spelling of this test anywhere would be a `3 want you` over four rows, which +// is the exact failure the one-reader law exists for. +// +// A LANDING THE CONVERSATION IS ITSELF ASKING ABOUT IS NOT ONE OF THESE +// ([needsAskedInChat]): that is a stopped conversation, it is a `needs you` row, +// and it is the row that can take a digit. +func needsCallOf(row session.SessionRow, entry session.TaskIndexEntry) (session.TaskStatus, bool) { + // WORK IN A CONVERSATION SOMEBODY PUT AWAY IS NOT WAITING ON THEM. Archiving + // is the decision to stop being asked about it, and every other panel already + // reads it that way ([machineCounts] skips an archived row outright). + if row.Archived { + return session.TaskStatus{}, false + } + status := taskEntryStatus(entry, row.Runs(entry)) + if !status.Attention || status.On != session.TaskWaitPerson || needsAskedInChat(row, entry) { + return session.TaskStatus{}, false + } + return status, true +} + +// needsCallAt is when a landing asked — when the work ended, or when it started +// for a row that never recorded an ending. +func needsCallAt(entry session.TaskIndexEntry) time.Time { + if !entry.EndedAt.IsZero() { + return entry.EndedAt + } + return entry.StartedAt +} + +// needsCall is one of those rows: the title, how many files it wrote and how +// long ago, and — under the cursor — what it came to and its two answers. func needsCall(project session.Project, row session.SessionRow, entry session.TaskIndexEntry, status session.TaskStatus, now time.Time) needsItem { title := strings.TrimSpace(entry.Label) if title == "" { title = strings.TrimSpace(entry.Title) } - sub := switcherFirstLine(status.Reason) - if sub == "" { - sub = status.Word - } - if status.Ask.Kind == session.TaskAskCheck { - sub = needsUncheckedWord - } - asked := entry.EndedAt - if asked.IsZero() { - asked = entry.StartedAt - } - cell := &homeCell{panel: panelNeeds, mark: cellMarkNeeds, title: title, right: sinceAt(asked, now), sub: sub, - key: needsCallKey + entry.ID} + asked := needsCallAt(entry) + // A LANDING WEARS NO MARK (law 8). The amber `?` means a thing has stopped + // and will not move until somebody answers it; a landing has already + // finished, and a column of question marks over work that is DONE was the + // screen saying the opposite of what was true. + cell := &homeCell{panel: panelNeeds, title: title, right: needsCallFacts(entry, asked, now), + key: needsCallKey + entry.ID, + grows: true, sub: needsCallSub(entry, status), answers: needsCallAnswers(status)} line := homeLine{kind: homeSession, row: row, project: project.Name, dir: homeBucketOf(row.Transcript), task: &entry, cell: cell} return needsItem{asked: asked, line: line} } +// needsCallSub is the line a landing grows under the cursor: THE FIRST SENTENCE +// OF WHAT THE WORK CAME TO, which is what a person needs in order to say whether +// it holds. The engine's own reason for the call stands in where the work left no +// report, and the tier's word where there is neither. +func needsCallSub(entry session.TaskIndexEntry, status session.TaskStatus) string { + if sub := switcherFirstLine(entry.Outcome); sub != "" { + return sub + } + if sub := switcherFirstLine(status.Reason); sub != "" { + return sub + } + return status.Word +} + +// needsCallFacts is a landing's right margin: how many files it wrote and how +// long ago, as ONE clause — `3 files · 1d`. +// +// IT IS ONE CLAUSE AND NOT TWO FACTS SIDE BY SIDE because they are read as one +// sentence about the same piece of work, and because a row cut between them +// would leave `3 files` with no age beside a column of rows that all have one. +// A landing that wrote no files says NOTHING where the count would be, never +// `0 files` (the emptiness law). +func needsCallFacts(entry session.TaskIndexEntry, asked, now time.Time) string { + age := sinceAt(asked, now) + if entry.FilesChanged <= 0 { + return age + } + files := itoa(entry.FilesChanged) + plural(" file", entry.FilesChanged) + if age == "" { + return files + } + return files + rowSep + age +} + +// The two keys a landing is answered with ON HOME, and the landing keys they +// stand for. +// +// ── WHY THEY ARE DIGITS HERE AND LETTERS EVERYWHERE ELSE ──────────────────── +// +// A landing is `[a] accept · [n] not right` on its card, in its room and on its +// record, and [session.LandingYesKey] is that letter (answers.go: the landing +// keys are task-states' own, letter for letter). HOME CANNOT OFFER A LETTER. Its +// foot promises "type to search or start something new" and the promise has no +// asterisk — a bare letter on this screen always types, whatever the cursor is +// resting on (home.go's [app.homeKey] states it), and the ONE printable +// exception it allows is a digit drawn on the row itself. An `a` that sometimes +// accepted a landing and sometimes began "a site for my café" is the mode that +// law exists to forbid. +// +// So the KEY is home's and the WORDS are the task's: `1 accept 2 not right`, +// read off [session.TaskAsk], mapped back to the landing's own keys before the +// answer leaves this surface ([needsLandingKey]). Nothing about the answer is +// renumbered — only the key a person presses on THIS screen, which is the same +// bargain the drawn digit has always been. +const ( + needsYesKey = "1" + needsNoKey = "2" +) + +// needsCallAnswers is the two answers a landing offers, in the task's own words +// on home's own two digits. +// +// THE WORDS ARE THE ASK'S ([session.TaskAsk.Yes] and .No) and this file spells +// none of them. A row whose ask has no words offers nothing, which is the +// absence law: there is no default pair to fall back on. +// +// AND EVERY ASK THAT REACHES HERE IS [session.TaskAskCheck]. The status comes +// from [session.TaskIndexEntry.StatusFacts], which fills the state, the ending +// and the kind and nothing else — the merge, the ground hold and the phase are +// the NODE's facts and the index does not carry them — so `taskAskOf` can only +// take its `check` arm for an index row. A conflict is a different question with +// different words, it is banked by the engine as `conflict:<id>` +// ([session.Agent.landingQuestion]), and it reaches a person through the task's +// own card and not through this panel. So [needsLandingQuestion] names one kind +// rather than guessing between two. +func needsCallAnswers(status session.TaskStatus) string { + yes, no := strings.TrimSpace(status.Ask.Yes), strings.TrimSpace(status.Ask.No) + if yes == "" || no == "" { + return "" + } + return needsYesKey + " " + yes + homeCellGap + needsNoKey + " " + no +} + +// needsLandingKey is home's digit as the landing key the engine answers to. +func needsLandingKey(key string) (string, bool) { + switch key { + case needsYesKey: + return session.LandingYesKey, true + case needsNoKey: + return session.LandingNoKey, true + } + return "", false +} + +// needsChecking is every landing the `to check` group is drawing, by the +// identity a `since you left` line carries for the same task +// (homepanel_left.go's [leftKey]) — so one piece of work is one row of home's +// column and never two. +func needsChecking(in *homeGridInput) map[string]bool { + said := make(map[string]bool, len(in.calls)) + for _, item := range in.calls { + if item.line.task != nil { + said[taskLedgerKey(*item.line.task)] = true + } + } + return said +} + // needsCallKey prefixes a task row's [homeCell.key], so a conversation's own // question and one of its tasks' calls are two rows and never one. const needsCallKey = "call:" @@ -222,3 +487,102 @@ func answersWord(question session.PresenceQuestion) string { } return strings.Join(parts, homeCellGap) } + +// ── answering a landing from home ─────────────────────────────────────────── + +// needsLandingQuestion is the landing question a `to check` row stands for, as +// the answer doors want it: the kind, the node's id, and the two options in the +// task's own words. It is false for a row that is not a landing or whose id the +// index never recorded. +// +// IT IS BUILT FROM THE ROW AND NEVER FROM A LIVE SESSION'S PRESENCE. That is the +// whole difference between this group and the one above it: nothing is asking, +// so there is no question object to read — the task index says the call is the +// person's and [session.TaskAsk] says what the two answers are called. +func needsLandingQuestion(line homeLine) (session.PresenceQuestion, bool) { + if line.task == nil || line.cell == nil || line.cell.answers == "" { + return session.PresenceQuestion{}, false + } + id, err := strconv.ParseUint(strings.TrimSpace(line.task.ID), 10, 64) + if err != nil || id == 0 { + return session.PresenceQuestion{}, false + } + yes, no, ok := needsAnswerWords(line.cell.answers) + if !ok { + return session.PresenceQuestion{}, false + } + return session.PresenceQuestion{Kind: session.QuestionLanding, ID: id, Options: []session.AnswerOption{ + {Key: needsYesKey, Label: yes}, + {Key: needsNoKey, Label: no, Safe: true}, + }}, true +} + +// needsAnswerWords reads the two words back out of the clause the row draws, so +// the chip a person sees and the label the receipt says are the SAME STRING and +// not two readings of one status. +func needsAnswerWords(clause string) (yes, no string, ok bool) { + parts := strings.SplitN(clause, homeCellGap, 2) + if len(parts) != 2 { + return "", "", false + } + yes = strings.TrimPrefix(parts[0], needsYesKey+" ") + no = strings.TrimPrefix(parts[1], needsNoKey+" ") + return yes, no, yes != "" && no != "" +} + +// homeAnswerLanding takes one of a landed task's own answers from home, and it +// is THE ONE DOOR on whichever side of the window the conversation is. +// +// In this window the answer goes straight to [session.Agent.ResolveQuestion], +// which is where the record card's `a` and `n` end up too ([app.taskCardKey] → +// [app.questionOptionKey] → the block). In any other window it is left on that +// conversation's doorstep ([app.leaveAnswer] → [session.WriteAnswer]), and that +// session's own heartbeat hands it to the SAME ResolveQuestion +// ([session.Agent.drainAnswers] → [session.Agent.applyLanding] → +// [session.Agent.ResolveUnverified]). There is no third path, and nothing here +// settles a task itself. +// +// A CONVERSATION THAT IS NOT RUNNING APPLIES IT WHEN IT NEXT RUNS, which is what +// this surface has always promised for another window's question and is why the +// row says `answered · waiting for it to pick that up` until it is picked up. +func (a *app) homeAnswerLanding(line homeLine, key string) (tea.Cmd, bool) { + question, ok := needsLandingQuestion(line) + if !ok { + return nil, false + } + label := question.Label(key) + answer, ok := needsLandingKey(key) + if label == "" || !ok { + return nil, false + } + row := a.homeTrue(line.row) + if _, sent := a.answerSent(row, question); sent { + // ONE ANSWER PER LANDING, for [app.answerRowKey]'s reason: the first is + // on its way and the row is still saying so. + return nil, false + } + if a.answeringHere(row) { + doors, ok := a.questionDoors() + if !ok { + return nil, false + } + sent := session.Answer{At: time.Now(), Kind: session.QuestionLanding, ID: question.ID, + Key: answer, Picked: []string{answer}, DecidedBy: session.DecidedByPerson} + if err := doors.ResolveQuestion(sent); err != nil { + return nil, false + } + a.home.say(answerSentWord+label, "") + return nil, true + } + dir := strings.TrimSpace(row.Dir) + if a.leaveAnswer == nil || dir == "" { + return nil, false + } + if err := a.leaveAnswer(dir, question.Kind, question.ID, answer); err != nil { + a.home.say(answerFailedWord, dir) + return nil, true + } + a.rememberAnswered(dir, question, label) + a.home.say(answerSentWord+label, "") + return nil, true +} diff --git a/internal/tui3/placeprose.go b/internal/tui3/placeprose.go index cbadb1188d..29d6677133 100644 --- a/internal/tui3/placeprose.go +++ b/internal/tui3/placeprose.go @@ -96,15 +96,21 @@ const ( standNotOursWord = "that one does not stand over this conversation" ) -// needsUncheckedWord is the line under a `needs you` row for work that landed -// and that nobody could check (homepanel_needs.go's [needsCall]). +// The two words the `to check` group of `needs you` is drawn with +// (homepanel_needs.go), spelled once here because the heading, the fold and the +// manual all quote them. // -// IT SAYS WHAT THE PERSON CAN DO, NOT WHAT THE MACHINE COULD NOT. The engine's -// own reason, `nobody could check it`, is the right sentence on the task's -// record, where the two answers stand beside it; on home, under a title and an -// age, it read as a shrug (owner, 2026-09-10). The line names its own door, so -// the row draws no second `enter` beside it. -const needsUncheckedWord = "landed unchecked · enter to look" +// THE CLAUSE IS THE WHOLE EXPLANATION AND IT IS SAID ONCE. The difference +// between a row that has stopped a conversation and a landing nobody has looked +// at is not obvious from a title and an age, and it used to be spelled under +// every landing — `landed unchecked · enter to look`, the same sub-line on nine +// rows (owner, 2026-09-11). It is seven words on the group's own line now, in +// the place a heading's clause already lives, and the rows under it are one line +// each. +const ( + needsCheckWord = "to check" + needsCheckClause = "finished, nobody has checked it" +) // ── THE FIVE-LEVEL SCALE (SCREEN 2a) ──────────────────────────────────────── //