From e059d76b06abcf74a89b5e4513f45337211ba6e6 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Tue, 15 Sep 2026 10:57:05 -0400 Subject: [PATCH 01/23] tui3: a home panel stands where its content puts it (#1046) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Home's law 2 was `act left, watch right` — the person's three panels on the left, the machine's four on the right, whatever any of them held. On a quiet machine that drew six whispering headings over a screen that was five sixths scaffolding, with the one panel that had rows boxed into a third of the width. The column is now a reading of the panel's content. A panel with rows stands in the FIELD, the columns left of the last one, filled from the top left corner down; a panel with nothing in it stands in the RAIL, the last column, flush right, as its heading and its whisper. `projects` and `spend` are pinned to the top of the rail because their height is the same on every machine on every day, and one blank row separates them from what is only quiet today. The rank inside a column never moves, which is what keeps the cost to one axis. The field fills rather than balances, so the cursor's panel is in the same column at every width the content fits in — a landing that moved with the frame would be a landing nobody can build a habit on — and a wide frame leaves its middle empty rather than spreading two short columns over it. `←→` now step over a column with nothing to stand on. Without that the rail would be out of the arrows' reach on any wide frame whose field fits in one column: projects and spend drawn on the screen with no key that walks to them. DESIGN.md carries the amended laws 2, 3 and 6 and says what each used to say. Co-Authored-By: Claude Opus 5 --- .../unreleased/1046-home-field-and-rail.md | 29 +++ docs/design/home-mission-control/DESIGN.md | 95 +++++--- internal/manual/chat/asking-from-home.md | 2 +- internal/manual/chat/home.md | 115 ++++++--- internal/tui3/home_test.go | 19 +- internal/tui3/homeattention_test.go | 28 ++- internal/tui3/homegrid.go | 220 +++++++++++++++--- internal/tui3/homegrid_test.go | 131 ++++++++--- internal/tui3/homepanel_live_test.go | 24 +- 9 files changed, 519 insertions(+), 144 deletions(-) create mode 100644 docs/changes/unreleased/1046-home-field-and-rail.md diff --git a/docs/changes/unreleased/1046-home-field-and-rail.md b/docs/changes/unreleased/1046-home-field-and-rail.md new file mode 100644 index 0000000000..323c621d27 --- /dev/null +++ b/docs/changes/unreleased/1046-home-field-and-rail.md @@ -0,0 +1,29 @@ +--- +kind: changed +title: A home panel stands where its content puts it, and the quiet ones gather in a rail on the right +pr: 1046 +surface: [chat, docs] +invalidates: + - "Home's law 2 was `act left, watch right`: the left column was the person's — `needs you`, `where you were`, `projects` — and the right column the machine's — `running`, `since you left`, `spend`, `next up`. No longer true, and this is not a tweak to the roster: a panel's column is no longer a fact about the panel at all. A panel WITH ROWS stands in the field, which is the columns left of the last one, filled from the top left corner down; a panel with NOTHING IN IT stands in the rail, the last column, flush with the right edge, as its heading and its whisper. So `running` is on the left while something is running and on the right while nothing is, and the side a panel is on now says whether it holds anything." + - "Home's law 3 said `a panel keeps its column and its rank in it`. The column half is struck. The rank half stands and is what keeps the cost to one axis: inside the field and inside the rail the order is still the order table's, so two panels that both fill never swap places. Anything that remembers where a named panel is drawn is wrong; what can still be relied on is the order, and that `projects` and `spend` are PINNED to the top of the rail whatever they hold, with one blank row under them separating what lives there from what is only quiet today." + - "The three-column tier put `projects` and `spend` in a column of their own and spread the other five over the first two. No longer true: the rail is the last column at both the two- and three-column tiers, and THE FIELD FILLS RATHER THAN BALANCES — a frame whose panels fit in one column leaves the middle of a wide screen empty instead of spreading two short columns over it. The empty middle is the ruling and not a gap in it." + - "`←→` crossed to the NEIGHBOURING column and did nothing where that column had no row to stand on. No longer true: the arrow now steps over a column with nothing in it to the next one that has a row. Without it the rail would be out of the arrows' reach entirely on a wide quiet frame — `projects` and `spend` drawn on the screen with no key that walks to them." + - "Home's resting foot was `type to search or start something new · ↑↓ pick · enter open · tab next place` on a quiet machine. It now carries the chord too — `… · enter open · ctrl+o open folder · tab next place`. The clause was always there on a machine whose right column had rows, because §6.6 refuses to leave a door invisible when `→` crosses columns rather than opening a row's verbs; what changed is that the rail always has rows now, so the condition is always met. The design's four keys still open the sentence and `esc` is still not on it." +--- + +The owner read a real quiet home and found six of the seven panels whispering +over a screen that was five sixths scaffolding: a fixed grid of headings holding +station over nothing, with the one panel that had anything in it boxed into a +third of the width. The old law bought a geography a person could learn, and on +a machine that is mostly idle — which is most machines most of the time — it +spent the whole screen on it. + +So the column became a reading of what the panel holds. What that costs is real +and was taken knowingly: home's geography moves now, and nobody can learn that +`running` is on the right. What it buys is that the left of the screen is only +ever the things that are actually going on, which is what a person walks up to +home to find. The rank inside each column never moves, so the cost is to one +axis and not to both. + +`docs/design/home-mission-control/DESIGN.md` carries the amended laws 2, 3 and 6 +and says what each of them used to say. diff --git a/docs/design/home-mission-control/DESIGN.md b/docs/design/home-mission-control/DESIGN.md index 4b04ea274f..0d1d819512 100644 --- a/docs/design/home-mission-control/DESIGN.md +++ b/docs/design/home-mission-control/DESIGN.md @@ -5,6 +5,14 @@ picker artifact is the visual record). Status: **ruled 2026-09-10** — the owner took every default in §6. Everything in §2 and §3 is verified against the tree on 2026-09-10; file references are to `dev` at `893d66067`.* +*Amended 2026-09-15: **laws 2 and 3 were re-ruled** by the owner after reading a +real quiet home — six of the seven panels whispering over a screen that was five +sixths scaffolding. A panel's column is now its content's to say: what has rows +is the field at the left, what has none gathers in a rail down the right edge, +and `projects` and `spend` are pinned to the top of that rail. The laws below +carry the change and say what each of them used to say; §1's screen is redrawn +under it.* + ## 1. The ruling in one screen Home is not a list of chats. It is a fixed set of **panels**, each answering one @@ -13,43 +21,74 @@ own keys, each a door to the place that owns it. The chat list is one panel. ``` codeaf $0.14 / $20.00 · thu 9:49am - home tasks 1 spend settings + home tasks spend settings ─────────────────────────────────────────────────────────────────────────────────────── - needs you · 2 running · 2 - ? Searching for Apartments Near Minto 2h ◐ Generate and Display First 200 Primes 4m - needs your ok to run bash 1 yes 2 not now working · checking - ? Clever Bet Prediction Model 6h npm run dev · pricing-site up 3h + needs you · 2 projects + ? Searching for Apartments Near Minto 2h ~/codeaf 12 chats · 1 running master, 2 dirty + needs your ok to run bash 1 yes 2 not now ~/pricing-site 5 chats · a job up main + ? Clever Bet Prediction Model 6h ~/infra 3 chats · quiet 4d 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 in Data … here made reports/apartments-minto-street.md - explain open addressing vs chaining the 6am repo watch found nothing changed - Understanding Bloom Filters in Eight … 11h learned 2 things about codeaf - AI Influencers and Developers in … 11h - Locate Recent Sandbox Task in … 1d spend today $0.14 of $20 - 70 more · type to find one ▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪ under a cent per chat - ▁▂▁▃▅▂▁▁▇▃▂▅▂▁ 14 days $34.10 · opus 63% - projects - ~/codeaf 12 chats · 1 running master, 2 dirty - ~/pricing-site 5 chats · a job up main next up - ~/infra 3 chats · quiet 4d the 6am repo watch in 20h - top movers before the open mon 8:30 - 2 rules hold + spend today $0.14 of $20 + where you were ▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪ under a cent per chat + › Understanding Hash Tables in Data … here ▁▂▁▃▅▂▁▁▇▃▂▅▂▁ 14 days $34.10 · opus 63% + explain open addressing vs chaining + Understanding Bloom Filters in Eight … 11h + AI Influencers and Developers in … 11h running · 2 + Locate Recent Sandbox Task in … 1d ◐ Generate and Display First 200 Primes 4m + 70 more · type to find one working · checking + npm run dev · pricing-site up 3h + since you left · 12h + Spark Fleet Ssh Audit · 2 hosts up, 1 not next up + made reports/apartments-minto-street.md the 6am repo watch in 20h + the 6am repo watch found nothing changed top movers before the open mon 8:30 + learned 2 things about codeaf 2 rules hold + ─────────────────────────────────────────────────────────────────────────────────────── › say what you want done here ~/codeaf type to reach anything · ↑↓ ←→ move · enter open · 1 2 answer · alt for the map ``` +The field on the left is every panel with rows, in the order table's order; the +rail on the right is the pinned pair and, under a blank row, whatever is quiet +today — here `running`, `since you left` and `next up` all hold something, so the +rail is only its pinned top. THE SCREEN A PERSON ACTUALLY SEES MOST DAYS is the +inverse of this one: one panel in the field and six headings in the rail, which +is the reading that produced the 2026-09-15 amendment. + ### The laws 1. **One primary action.** The box. Everything above it sets up what you type or where you press enter. -2. **Act left, watch right.** Left column is the person's: `needs you`, `where - you were`, `projects`. Right column is the machine's: `running`, `since you - left`, `spend`, `next up`. One column under 110 cells, two to 170, three past it. -3. **Stable order, flexible height.** A panel keeps its column and its rank in - it. Its height is what it holds. Position is a rank, not a pixel. +2. **The field and the rail** (ruled 2026-09-15, replacing *act left, watch + right*). A panel with rows stands in the **field** — the columns left of the + last one, filled from the top left corner down. A panel with nothing in it + stands in the **rail**, the last column, flush with the right edge, as its + heading and its whisper. `projects` and `spend` are **pinned** to the top of + the rail whatever they hold, because their height is the same on every + machine on every day; one blank row separates that pair from the panels that + are in the rail only because they are quiet today. One column under 110 + cells, two to 170, three past it — and the field FILLS rather than balances, + so a frame whose panels fit in one column leaves the middle of a wide screen + empty rather than spreading two short columns over it. + + *What this law was.* Until 2026-09-15 the column was a fact about the panel: + the person's three on the left, the machine's four on the right. It was + replaced because on a quiet machine — which is most machines most of the time + — six of the seven panels held nothing, and the screen was five sixths + scaffolding standing over the one panel with anything in it. The trade is + named in law 3. +3. **Stable RANK, flexible column and height** (ruled 2026-09-15). A panel keeps + its rank in whichever column it lands in, so two panels that both fill never + swap places. Its height is what it holds. Its column is its content's to say + (law 2). Position is a rank, not a pixel. + + *What this law was.* It read "a panel keeps its column and its rank in it". + The column half was struck knowingly: home's geography now moves, and a + person cannot learn that `running` is on the right the way they could before. + What is bought with it is that the left of the screen is only ever the things + that are actually going on, which is the thing a person walks up to home to + find. The rank never moving is what keeps the cost to one axis. 4. **An empty panel whispers.** It stays as its heading and one dim line that names what arrives here and the one thing that puts it there — never that the panel is empty. @@ -262,8 +301,10 @@ useful. 4. **The emptiness law narrows for panels** — written into `docs/DESIGN-LANGUAGE.md` ("presence over labels") and CLAUDE.md's design-laws list in this branch. 5. **Cross-window stop stays absent**, not broken; the row says `another window`. -6. **Columns win the arrow.** `←→` cross columns, and `→` opens a row's verb strip - only where no column with rows lies to its right. A row whose verbs `→` cannot +6. **Columns win the arrow.** `←→` cross columns, stepping over a column with + nothing to stand on to the next that has a row — without which the rail would + be out of the arrows' reach on a wide frame whose field fills one column. `→` + opens a row's verb strip only where no column with rows lies to its right. A row whose verbs `→` cannot reach keeps them on their chords (`ctrl+o`, `ctrl+e`, …), and the foot names one so the door is never invisible. 7. **A whisper never outranks a row.** When a squeeze has to drop a whole panel, every diff --git a/internal/manual/chat/asking-from-home.md b/internal/manual/chat/asking-from-home.md index f37029534c..85ca9f1399 100644 --- a/internal/manual/chat/asking-from-home.md +++ b/internal/manual/chat/asking-from-home.md @@ -228,7 +228,7 @@ pane fills the frame; drag it wide again and it goes back beside the list. ## Why can't I click a row while asking — you can, and it opens it -You can, and it does. A click on any row of the left column puts the cursor on it, gives +You can, and it does. A click on any row of the list puts the cursor on it, gives the keyboard to the column and opens the row, exactly as `enter` on it would — one click, the same as on every place. A click on the exchange's own row gives its pane the keyboard, and a click on `ask here` or on the row of what you typed only puts the cursor there, diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index f74cd1a681..eb1502346c 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -11,33 +11,49 @@ answering one question you would ask walking up to a colleague's desk: home tasks spend settings ─────────────────────────────────────────────────────────────────────────────────────── - needs you · 2 running · 2 - ? Searching for Apartments Near Minto 2h ⠋ Generate and Display First 200 Primes 4m - needs your ok to run bash 1 allow once 2 always 3 deny bash · 12s · 2 of 5 - ? 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 - 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 + needs you · 2 projects · folders you've opened + ? Searching for Apartments Near Minto 2h ~/codeaf 12 chats · 1 running master + needs your ok to run bash 1 allow once 2 always ~/pricing-site 5 chats + ? Clever Bet Prediction Model 6h + the 2024 season only, or all three? enter spend today $0.14 of $20 + ▁▂▁▃▅▂▁▁▇▃▂▅▂▁ 14 days $34.10 + to check · 3 finished, nobody has checked it opus 63% · 3 chats and 1 task today + tier-B subs 3 files · 1d 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 + package release radar 1d + 1 more · 15 older · tasks + where you were - Understanding Hash Tables here next up - explain open addressing vs chaining the 6am repo watch in 20h + Understanding Hash Tables here + explain open addressing vs chaining Understanding Bloom Filters 11h 70 more · type to find one - projects next up - ~/codeaf 12 chats · 1 running master the 6am repo watch in 20h - ~/pricing-site 5 chats + running · 2 + ⠋ Generate and Display First 200 Primes 4m + bash · 12s · 2 of 5 + npm run dev · pricing-site · a background job + + since you left · 12h + Spark Fleet Ssh Audit · 2 hosts up, 1 not + made apartments-minto-street.md + + next up + the 6am repo watch in 20h ``` +Everything with rows in it is on the **left**, in the fixed order of the seven panels. +`projects` and `spend` are pinned at the **top right** and stay there. On a quieter machine +most of those left-hand panels have nothing in them, and they gather under the pinned pair +in the right-hand rail instead — heading and one dim line each — so the left of the screen +is only ever the things that are actually going on. + `esc` puts you back in exactly the chat you came from, untouched — nothing was closed and -nothing was sent while you were looking. The resting foot reads exactly `type to search or -start something new · ↑↓ pick · enter open · tab next place`, and `alt+.` draws the whole -map over the cells you are already reading. +nothing was sent while you were looking. The resting foot reads `type to search or start +something new · ↑↓ pick · enter open · ctrl+o open folder · tab next place` — the chord is +there because `→` on a field row crosses to the rail rather than opening the row's verbs, and +a door you cannot see is a door you never learn. `alt+.` draws the whole map over the cells +you are already reading. There is no argument form. The screen is how you name what you want; a command that took a project name would be asking you to type out the very thing home exists to show you. @@ -48,10 +64,26 @@ things stand, and you either act on something or leave. ## See everything at once — what is on the home screen, the seven panels, and what an empty one says -**Act on the left, watch on the right.** The left column is yours — `needs you`, `where -you were`, `projects`. The right column is the machine's — `running`, `since you left`, -`spend`, `next up`. One column under 110 cells, in that order; two columns from 110; three -from 170, where `projects` and `spend` take the third. +**What has something in it is on the left; everything else is a rail down the right +edge.** A panel with rows stands in the **field** — the left of the screen, filled from the +top left corner down. A panel with nothing in it stands in the **rail**, flush with the +right edge, as its heading and its one dim line. So the panels move between the two sides as +work arrives and finishes, and the side a panel is on tells you whether it holds anything +before you have read a word of it. + +**`projects` and `spend` are pinned to the top of the rail** and never move, because their +height is the same on every machine on every day. A blank row separates that pair from the +panels below them, which are in the rail only because they are quiet today. + +**The rank never moves, only the side.** Within the field and within the rail the order is +always `needs you`, `where you were`, `projects`, `running`, `since you left`, `spend`, +`next up` — so two panels that both fill never swap places. + +One column under 110 cells, where every panel is in that one order and there is no rail; +two columns from 110; three from 170, where the rail is the third and the field fills the +first. **A field that fits in one column leaves the middle of a wide screen empty** rather +than spreading two short columns over it — the point of the one busy column standing alone +at the left is that your eye has a single place to go. | Panel | What a row is | `enter` on a row | Dim line when it holds nothing | | --- | --- | --- | --- | @@ -71,8 +103,10 @@ 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.** The panel holds **two groups**: the questions that have -stopped something, then the work that landed and has not been checked. +**The first panel of the field whenever it holds anything**, so a question waiting on you +is at the top left corner of the screen. The panel holds **two groups**: the questions that +have stopped something, then the work that landed and has not been checked. With nothing +waiting it is in the rail down the right edge, as its heading and its dim line. **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: @@ -224,8 +258,9 @@ not where it is settled.** ## What is running — the running panel: what is running on this machine right now, and what went in the moving column -**The `running` panel, at the top of the right column, is every piece of work out on the -machine** — every task and adaptive run, every background job a conversation started, and +**The `running` panel is every piece of work out on the machine**, in the field with the +other panels that hold something — under `needs you` and `where you were`, which outrank it +— and in the rail on the right when nothing is out — every task and adaptive run, every background job a conversation started, and every watch or reminder in the middle of firing — the most recently started first. A row is a piece of work and not a conversation: a chat with three tasks out is three rows. @@ -252,9 +287,10 @@ column or `◐` mark any more; this panel is that list. **`s` stops only what this window is running.** `→` on a task this window's own conversation holds offers `s stop it`, which asks you first, and **`ctrl+x`** asks the same -from any column with no strip. On a three-column home `running` is the middle column, so -`→` crosses to the next column instead of opening the verbs, and the foot names the chord: -`ctrl+x stop it`. **A task another window runs cannot be stopped from here** — the row +from any column with no strip. A `running` row with rows in it is in the field, and the rail +is always a column with rows to its right — so `→` crosses to the rail instead of opening +the verbs, and the foot names the chord: `ctrl+x stop it`. Where the column the arrow would +reach is empty, it steps over it to the next one that has a row. **A task another window runs cannot be stopped from here** — the row offers no stop at all; bring it here with `enter` and stop it there. The pulse inside a chat counts the same work: `1 moving` is these rows. @@ -279,8 +315,9 @@ spinner. In screen-reader (linear) mode nothing turns at all. ## What did it do while I was away — the since you left panel, what happened while the terminal was shut -**What happened on its own while you were not looking**, in the right column under -`running`, headed with how long you were away: +**What happened on its own while you were not looking**, under `running` — in the field +when it holds anything and in the rail when it does not — headed with how long you were +away: ``` since you left · 12h @@ -403,7 +440,7 @@ each of those is a panel of its own now. | `◐` rows under them | the `running` panel | | the quiet rows and `▸ 15 more, quiet since 6d` | `where you were`, then `N more · type to find one` | | project headings, `alt+g` | the `projects` panel | -| the `since you left` ledger above the list | the `since you left` panel, right column | +| the `since you left` ledger above the list | the `since you left` panel | | the card on the right | nothing at rest; a card still stands beside a search on a wide frame | ## How do I group home by project — alt+g @@ -437,7 +474,8 @@ wears the same ground and its title goes bold; the row under your mouse pointer while the pointer is on it. **Columns win the arrow: `→` opens a row's verbs only where no column with rows lies to its -right** — on the right column, and everywhere at one column. On a row where `→` crosses +right** — on the rail, and everywhere at one column. Where the next column along is empty +the arrow steps over it to the next one that has a row, so the rail is always reachable. On a row where `→` crosses instead, **the foot names the chord that still reaches them** — `ctrl+o open folder`, or `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. @@ -1828,8 +1866,9 @@ conversation's `a put it away` and, where it has a folder, `t new chat here`, `o open folder`, `c copy path`; a standing item's `p pause it` or `r resume it`; a task this window runs, `s stop`; a project's `its chats` and `open folder`. -**On the left column `→` crosses to the right instead** — columns win the arrow — so a -question there is answered with its digit, and a conversation's verbs are on their chords — +**In the field `→` crosses to the rail instead** — columns win the arrow, and the rail +always has rows in it because `projects` is pinned there — so a question in the field is +answered with its digit, and a conversation's verbs are on their chords — `ctrl+e`, `ctrl+o`, `ctrl+y`, `ctrl+t`. The foot names the one that matters on such a row: `ctrl+o open folder`, or `ctrl+e pause` on a standing order. @@ -1988,7 +2027,7 @@ have. ## What is next up on home — reminders and routines coming soon -**The `next up` panel, at the foot of the right column**: every reminder and routine this +**The `next up` panel, last of the seven**: every reminder and routine this machine will act on, from every project, **soonest first**, with the rules that simply hold at the end. Each row is your own words, with when it goes off at the right — `in 20h`, `mon 8:30`, `holds`: @@ -2010,7 +2049,7 @@ up. On a short terminal `next up` is the first panel to give way. ## How much did today cost — the spend panel on home -**The `spend` panel, in the right column**, is the day and the fortnight in three dim lines, +**The `spend` panel, pinned in the rail under `projects`**, is the day and the fortnight in three dim lines, and every line is a door into the spend place: ``` diff --git a/internal/tui3/home_test.go b/internal/tui3/home_test.go index 8dd6d1eb49..80fbe46bec 100644 --- a/internal/tui3/home_test.go +++ b/internal/tui3/home_test.go @@ -922,8 +922,21 @@ func TestHomesRestingFootIsTheDesignsSentence(t *testing.T) { } // The last row is the hint and the row above it is the box, which is the // order pages.go assembles every place's foot in. - if got := strings.TrimSpace(ansi.Strip(lines[len(lines)-1])); got != design { - t.Fatalf("the resting hint reads %q, want %q", got, design) + // + // THE RESTING ROW CARRIES ONE CHORD BESIDE THE DESIGN'S FOUR KEYS, and it is + // the door §6.6 refuses to leave invisible: the rail is a column with rows in + // it (projects is pinned there and is never empty), so `→` on a field row + // crosses columns rather than opening the row's verbs, and the foot names the + // one key that still reaches them ([app.homeCrossChord]). It was already on + // this row on any machine whose right column had rows; what the 2026-09-15 + // ruling changed is that the right column now always does. + rest := strings.TrimSpace(ansi.Strip(lines[len(lines)-1])) + want := strings.Replace(design, " · tab next place", rowSep+homeFolderChordWord+" · tab next place", 1) + if rest != want { + t.Fatalf("the resting hint reads %q, want %q", rest, want) + } + if !strings.HasPrefix(rest, "type to search or start something new · ↑↓ pick · enter open") { + t.Fatalf("the resting hint no longer opens with the design's own words: %q", rest) } // THE BOX ROW IS THE SAME SENTENCE AS EVERY OTHER PLACE'S (SCREEN 2b). What // home's box ALSO does — filter the list — is said on the hint above, which is @@ -936,7 +949,7 @@ func TestHomesRestingFootIsTheDesignsSentence(t *testing.T) { } // AND THE CLAUSE THAT LEFT IS REALLY GONE from the foot — not merely absent // from the constant this test already compared. - for _, row := range []string{box, design} { + for _, row := range []string{box, rest} { if strings.Contains(row, "esc") { t.Fatalf("the resting foot names esc: %q", row) } diff --git a/internal/tui3/homeattention_test.go b/internal/tui3/homeattention_test.go index d08f8605ab..a8d9131f20 100644 --- a/internal/tui3/homeattention_test.go +++ b/internal/tui3/homeattention_test.go @@ -267,7 +267,13 @@ func TestOneBlankRowSeparatesTheBlocksOfTheList(t *testing.T) { if at == 0 || h.columnOf(at-1) != h.columnOf(at) { t.Fatalf("a column opened with a blank row:\n%s", homeText(a)) } - if h.lines[at-1].kind == homeBlank { + // THE RAIL'S ONE GROUP GAP IS THE EXCEPTION AND THE ONLY ONE. A second + // blank row is allowed where the rail stops being the panels that live + // there and starts being the panels that are quiet today + // ([markRailGap]) — the grid draws no rules, so air is the only thing it + // has to tell two groups apart with. Anywhere else two blanks are still + // a bug. + if h.lines[at-1].kind == homeBlank && !railGapAt(h, at) { t.Fatalf("two blank rows stand between two blocks at line %d:\n%s", at, homeText(a)) } if at+1 >= len(h.lines) || h.columnOf(at+1) != h.columnOf(at) { @@ -279,6 +285,26 @@ func TestOneBlankRowSeparatesTheBlocksOfTheList(t *testing.T) { } } +// railGapAt reports the second blank of the rail's own group gap: the rail's +// column, and the next line a cursor may see is the heading of a panel that is +// in the rail because it is quiet rather than because it is pinned. +func railGapAt(h *homeView, at int) bool { + if h.cols < 2 || h.columnOf(at) != homeRailCol(h.cols) { + return false + } + for next := at + 1; next < len(h.lines); next++ { + if h.columnOf(next) != h.columnOf(at) { + return false + } + line := h.lines[next] + if line.kind == homeBlank { + continue + } + return line.cell != nil && line.cell.kind == cellHead && !homeSlotOf(line.cell.panel).pinned + } + return false +} + // A ROW OF THE LIST IS A DOOR OF THE KIND IT ALWAYS WAS. The card beside it is // that conversation's, a digit over it answers the window it belongs to through // the road the answer band already rides, and enter walks the conversation's own diff --git a/internal/tui3/homegrid.go b/internal/tui3/homegrid.go index ab34b58bef..a6e7a54f54 100644 --- a/internal/tui3/homegrid.go +++ b/internal/tui3/homegrid.go @@ -127,9 +127,13 @@ type homePanelSlot struct { // heading is the one that counts live questions, so a gloss there would // repeat the panel and stack a third clause on the count. explainer string - // col2 and col3 are the column this panel stands in at two and at three - // columns. One column is every panel in table order. - col2, col3 int + // pinned is a panel that stands in the rail whatever it holds. It is for a + // panel whose height is the same on every machine on every day — spend is + // three rows forever, and projects is the folders you have opened — so + // moving it into the field would teach a person a position that then never + // changes back. Every other panel's column is its content's to say + // ([homeColumnOf]). + pinned bool // keep is the squeeze priority: the LOWEST gives way first (law 5). keep int // least is how many rows the panel keeps when it is squeezed, heading and @@ -153,11 +157,16 @@ type homePanelSlot struct { } // homePanelOrder is THE ORDER TABLE (DESIGN §1 law 2 and law 5). Its row order -// is the rank inside a column; at one column it is the reading order too, which -// is the two-column page read left column first. +// is the rank a panel takes wherever it stands, and at one column it is the +// reading order too. // -// two columns needs · recent · projects | running · left · spend · next -// three columns needs · recent | running · left · next | projects · spend +// IT NO LONGER SAYS WHICH COLUMN A PANEL IS IN, because the column is a reading +// of what the panel holds rather than a fact about the panel (law 2, ruled +// 2026-09-15). What the table still fixes is the rank inside whichever column +// the panel lands in, so two panels that both fill never swap places: +// +// field (panels with rows) needs · recent · running · left · next +// rail (pinned, then quiet) projects · spend · the quiet ones in the same rank // // The keep column is the order a short frame takes rows away in and a tall one // hands them out in, and rest and most are each panel's natural height and its @@ -167,13 +176,13 @@ type homePanelSlot struct { // place but itself, so its heading opens nothing. The explainer is the dim // clause a heading may carry after its word — see [homePanelSlot.explainer]. var homePanelOrder = []homePanelSlot{ - {panel: needsPanel{homePanelBase{panelNeeds}}, word: "needs you", col2: 0, col3: 0, keep: 6, least: 4, rest: 4, most: 8, place: pageTasks, head: pageTasks}, - {panel: recentPanel{homePanelBase{panelRecent}}, word: "where you were", explainer: "enter reopens one", col2: 0, col3: 0, keep: 5, least: 4, rest: 5, most: 10, more: homeFindWord, head: pageSearch}, - {panel: projectsPanel{homePanelBase{panelProjects}}, word: "projects", explainer: "folders you've opened", col2: 0, col3: 2, keep: 4, least: 3, rest: 5, most: 8, more: homeFindWord}, - {panel: runningPanel{homePanelBase{panelRunning}}, word: "running", explainer: "work you sent off", col2: 1, col3: 1, keep: 3, least: 4, rest: 4, most: 8, place: pageTasks, head: pageTasks}, - {panel: leftPanel{homePanelBase{panelLeft}}, word: "since you left", col2: 1, col3: 1, keep: 2, least: 3, rest: 4, most: 8, place: pageTasks, head: pageTasks}, - {panel: spendPanel{homePanelBase{panelSpend}}, word: "spend", col2: 1, col3: 2, keep: 1, least: 3, rest: 3, most: 3, place: pageSpend, head: pageSpend}, - {panel: nextPanel{homePanelBase{panelNext}}, word: "next up", explainer: "reminders & routines", col2: 1, col3: 1, keep: 0, least: 3, rest: 3, most: 5, place: pageStanding, head: pageStanding}, + {panel: needsPanel{homePanelBase{panelNeeds}}, word: "needs you", keep: 6, least: 4, rest: 4, most: 8, place: pageTasks, head: pageTasks}, + {panel: recentPanel{homePanelBase{panelRecent}}, word: "where you were", explainer: "enter reopens one", keep: 5, least: 4, rest: 5, most: 10, more: homeFindWord, head: pageSearch}, + {panel: projectsPanel{homePanelBase{panelProjects}}, word: "projects", explainer: "folders you've opened", pinned: true, keep: 4, least: 3, rest: 5, most: 8, more: homeFindWord}, + {panel: runningPanel{homePanelBase{panelRunning}}, word: "running", explainer: "work you sent off", keep: 3, least: 4, rest: 4, most: 8, place: pageTasks, head: pageTasks}, + {panel: leftPanel{homePanelBase{panelLeft}}, word: "since you left", keep: 2, least: 3, rest: 4, most: 8, place: pageTasks, head: pageTasks}, + {panel: spendPanel{homePanelBase{panelSpend}}, word: "spend", pinned: true, keep: 1, least: 3, rest: 3, most: 3, place: pageSpend, head: pageSpend}, + {panel: nextPanel{homePanelBase{panelNext}}, word: "next up", explainer: "reminders & routines", keep: 0, least: 3, rest: 3, most: 5, place: pageStanding, head: pageStanding}, } // homeFindWord is what a fold says where the rest are reached by typing rather @@ -234,13 +243,33 @@ func homeSlotOf(id homePanelID) homePanelSlot { return homePanelSlot{} } -// column is which column a panel stands in at this many columns. -func (s homePanelSlot) column(cols int) int { - switch cols { - case 3: - return s.col3 - case 2: - return s.col2 +// railCol is the column the rail is: the LAST one the ladder drew, so the rail +// is flush with the right edge at every width it exists at. One column has no +// rail — everything is read top to bottom in table order. +func homeRailCol(cols int) int { return max(0, cols-1) } + +// homeColumnOf is which column a panel stands in, and it is THE LAW OF THE +// GRID (DESIGN §1 law 2, ruled 2026-09-15): the field is what has something in +// it, the rail is everything else. +// +// A panel with rows goes in the FIELD — the columns left of the rail — because +// the thing with content is the thing being read, and reading wants the left +// edge and the width. A panel with nothing in it goes in the RAIL, where its +// heading and its whisper stand as a list of what this machine could be doing +// and is not. A pinned panel is in the rail whatever it holds. +// +// THE COST OF THIS LAW IS THAT HOME'S GEOGRAPHY MOVES, and it is paid on +// purpose. The old law kept every panel in one column forever so a person could +// learn where to look; this one spends that to keep the eye on the one part of +// the screen where anything is happening, which on a quiet machine is a very +// small part. The rank inside each column never moves ([homePanelOrder]), so +// what changes is which side a panel is on and never the order it is found in. +func homeColumnOf(slot homePanelSlot, cols int, empty bool) int { + if cols <= 1 { + return 0 + } + if slot.pinned || empty { + return homeRailCol(cols) } return 0 } @@ -483,6 +512,11 @@ type homeGridPanel struct { // gone from the page. shown int dropped bool + // gapAbove is a second blank row over this panel's heading. It is set on the + // first quiet panel of the rail, so the pinned pair at the top is read as a + // group that lives there and the panels under it as the ones that are only + // passing through ([homeGridLayout]). + gapAbove bool } // natural is how many rows the panel shows at its natural height: its rest, @@ -569,6 +603,18 @@ func homeColumnHeight(column []*homeGridPanel) int { if drawn > 1 { n += drawn - 1 } + // AND THE RAIL'S OWN GAP IS A ROW OF THE COLUMN. A squeeze that did not + // count it would fit the column to the row below the screen. + first := true + for _, p := range column { + if p.height() == 0 { + continue + } + if p.gapAbove && !first { + n++ + } + first = false + } return n } @@ -700,26 +746,114 @@ func (p *homeGridPanel) shrink() { } // homeGridLayout reads every panel and fits each column: the panels in table -// order, each in the column the ladder puts it in, at that column's width. +// order, each in the column its own content puts it in ([homeColumnOf]), at +// that column's width. +// +// THE FIELD IS EVERY COLUMN LEFT OF THE RAIL, AND IT FILLS FROM THE LEFT. A +// panel with rows takes the first field column its height still fits in, and +// the next only when that one is full — so the first panel with rows is at the +// top left corner at every width, and a frame with one column's worth of +// content leaves the middle of the screen empty rather than spreading two short +// columns over it. +// +// IT DOES NOT BALANCE THE COLUMNS, and that is the point rather than a +// shortcoming. Balancing put whichever panel the cursor was resting in into +// whichever column had room, so `↓` off the tab bar landed on a different panel +// at 120 cells than at 200 — and the landing is the same line at every width +// ([homeView.placesTop], homebridge.go), because a landing that moves with the +// frame is a landing nobody can build a habit on. Filling from the left keeps +// the cursor's panel in the same column at every width the content fits in. func homeGridLayout(in *homeGridInput, cols, width, room int) [][]*homeGridPanel { columns := make([][]*homeGridPanel, cols) _, widths := homeGridGeometry(width, cols) + rail := homeRailCol(cols) + field := make([]int, max(1, rail)) for _, slot := range homePanelOrder { read := slot.panel.rows(in) - at := slot.column(cols) p := &homeGridPanel{slot: slot, read: read} p.shown = p.natural() + at := homeColumnOf(slot, cols, p.empty()) + if at != rail { + at = fillField(field, p.height(), room) + } if p.empty() { p.whisper = homeWhisperLines(slot.panel.whisper(), widths[at]) } + if at != rail { + field[at] += p.height() + 1 + } columns[at] = append(columns[at], p) } + // ONE COLUMN HAS NO RAIL AND THEREFORE NO GROUPS IN IT. Every panel is in + // the one column in table order, which is the reading order the narrow + // frame has always had — sorting the pinned pair to the top there would put + // projects and spend above the question waiting to be answered. + if cols > 1 { + columns[rail] = orderRail(columns[rail]) + markRailGap(columns[rail]) + } for _, column := range columns { fitColumn(column, room) } return columns } +// fillField is the field column a panel this tall goes in: the first from the +// left with the room for it, and the last when none has ([homeGridLayout] says +// why it fills rather than balances). A frame with no room at all — the build +// before the first one knows a height — keeps everything in the first column, +// where the squeeze will find it. +func fillField(field []int, height, room int) int { + if room <= 0 { + return 0 + } + for at, filled := range field { + if filled == 0 || filled+height <= room { + return at + } + } + return len(field) - 1 +} + +// orderRail puts the pinned panels at the top of the rail and the quiet ones +// under them, each group in table order. THE TOP OF THE RAIL IS THE PART THAT +// DOES NOT MOVE: projects and spend are there on every machine on every day, so +// they are the corner a person can aim at, and a panel that is only in the rail +// because it is quiet today is never drawn above them. +func orderRail(rail []*homeGridPanel) []*homeGridPanel { + out := make([]*homeGridPanel, 0, len(rail)) + for _, p := range rail { + if p.slot.pinned { + out = append(out, p) + } + } + for _, p := range rail { + if !p.slot.pinned { + out = append(out, p) + } + } + return out +} + +// markRailGap puts the rail's one extra blank over the first panel that is +// there because it is quiet rather than because it is pinned. THE PINNED PAIR +// READS AS THE TOP OF THE RAIL and the quiet ones as a list under it, which is +// the whole difference between a panel that lives in the rail and a panel that +// is in it today. +func markRailGap(rail []*homeGridPanel) { + seen := false + for _, p := range rail { + if p.slot.pinned { + seen = true + continue + } + if seen { + p.gapAbove = true + } + return + } +} + // homeWhisperLines is a whisper at one column's width, standing in a row's lead. // // A WHISPER WRAPS; IT IS NEVER CUT. It is the one sentence an empty panel has, @@ -764,6 +898,11 @@ func (h *homeView) buildGrid() { } if !first { h.addGridLine(homeLine{kind: homeBlank}, at) + // AND THE RAIL'S GROUPS ARE TOLD APART BY AIR AND NOTHING ELSE + // — the grid draws no rules ([homeGridGutter]). + if p.gapAbove { + h.addGridLine(homeLine{kind: homeBlank}, at) + } } first = false for _, line := range lines { @@ -963,24 +1102,31 @@ func (h *homeView) gridCross(dir int) bool { // the arrow keeps its other meaning. The foot asks it too, to know whether `→` // on the row under the cursor is a move or the strip ([app.homeCrossChord]). func (h *homeView) gridCrossTarget(dir int) int { - next := h.columnOf(h.cursor) + dir - if next < 0 || next >= h.grid.cols { - return -1 - } y := h.rowOf(h.cursor) - best, gap := -1, 0 - for _, at := range h.columnStops(next) { - d := h.rowOf(at) - y - if d < 0 { - d = -d + // A COLUMN WITH NOTHING TO STAND ON IS NO COLUMN THAT WAY — every panel in + // it is whispering, or the field never filled it at all — so the arrow STEPS + // OVER IT and asks the next one. It stopped at the neighbour until the field + // began leaving its second column empty (law 2, ruled 2026-09-15), and a + // crossing that stopped there would put the rail out of the arrows' reach + // entirely on a wide quiet frame: projects and spend would be drawn on the + // screen with no key that walks to them. + for next := h.columnOf(h.cursor) + dir; next >= 0 && next < h.grid.cols; next += dir { + best, gap := -1, 0 + for _, at := range h.columnStops(next) { + d := h.rowOf(at) - y + if d < 0 { + d = -d + } + if best < 0 || d < gap { + best, gap = at, d + } } - if best < 0 || d < gap { - best, gap = at, d + if best >= 0 { + return best } } - // A COLUMN WITH NOTHING TO STAND ON IS NO COLUMN THAT WAY: every panel in - // it is whispering, and the arrow keeps its other meaning. - return best + // AND WHERE NO COLUMN THAT WAY HAS A ROW, the arrow keeps its other meaning. + return -1 } // homeGridCross is `←` and `→` on the resting grid: the neighbouring column. diff --git a/internal/tui3/homegrid_test.go b/internal/tui3/homegrid_test.go index 1caeac1162..8b0324896d 100644 --- a/internal/tui3/homegrid_test.go +++ b/internal/tui3/homegrid_test.go @@ -70,35 +70,94 @@ func TestAtEightyTheGridIsOneColumnInReadingOrder(t *testing.T) { } } -// AT A HUNDRED AND TWENTY THE PERSON'S PANELS ARE ON THE LEFT AND THE MACHINE'S -// ON THE RIGHT, and the two columns start on the same row. -func TestAtOneTwentyThePersonActsLeftAndTheMachineIsWatchedRight(t *testing.T) { +// AT A HUNDRED AND TWENTY WHAT HAS ROWS IS THE FIELD AND EVERYTHING ELSE IS THE +// RAIL (law 2, ruled 2026-09-15), and the two columns start on the same row. +func TestAtOneTwentyWhatHasRowsTakesTheFieldAndTheQuietGatherInTheRail(t *testing.T) { a := newSwitchLab(t).open(120, 45) frame := homeText(a) needs, needsCol := homeRowOf(frame, "needs you") - running, runningCol := homeRowOf(frame, "running") - if needs < 0 || needs != running || runningCol <= needsCol { - t.Fatalf("needs you (row %d) and running (row %d) are not the heads of two columns:\n%s", needs, running, frame) + projects, railCol := homeRowOf(frame, "projects") + if needs < 0 || needs != projects || railCol <= needsCol { + t.Fatalf("needs you (row %d) and projects (row %d) are not the heads of the field and the rail:\n%s", needs, projects, frame) + } + // WHERE YOU WERE HAS ROWS, SO IT IS IN THE FIELD under the panel that + // outranks it — the rank inside a column is the order table's as it always + // was, and only the column is the content's to say. + recent, recentCol := homeRowOf(frame, "where you were") + if recent <= needs || recentCol != needsCol { + t.Fatalf("where you were has rows and is not under needs you in the field:\n%s", frame) + } + // AND SPEND IS PINNED UNDER PROJECTS AT THE TOP OF THE RAIL, with the quiet + // panels under the pair rather than mixed through it. + spend, spendCol := homeRowOf(frame, "spend") + if spend <= projects || spendCol != railCol { + t.Fatalf("spend is not pinned under projects at the top of the rail:\n%s", frame) } - projects, projectsCol := homeRowOf(frame, "projects") - if projects <= needs || projectsCol != needsCol { - t.Fatalf("projects is not under needs you in the left column:\n%s", frame) + quiet, quietCol := homeRowOf(frame, "since you left") + if quiet <= spend || quietCol != railCol { + t.Fatalf("a quiet panel is not in the rail under the pinned pair:\n%s", frame) } - spend, spendCol := homeRowOf(frame, "spend") - if spend <= running || spendCol != runningCol { - t.Fatalf("spend is not under running in the right column:\n%s", frame) +} + +// AND THE RAIL TELLS ITS TWO GROUPS APART WITH AIR: the pinned pair that lives +// there, then a blank row that is not the ordinary one between panels, then the +// panels that are only there because they are quiet today. +func TestTheRailKeepsTheQuietPanelsAnExtraRowBelowThePinnedPair(t *testing.T) { + a := newSwitchLab(t).open(120, 45) + frame := homeText(a) + lines := strings.Split(frame, "\n") + spend, railCol := homeRowOf(frame, "spend") + quiet, _ := homeRowOf(frame, "since you left") + if spend < 0 || quiet < 0 { + t.Fatalf("the rail is not drawn:\n%s", frame) + } + blank := 0 + for y := spend + 1; y < quiet; y++ { + if strings.TrimSpace(string([]rune(lines[y])[min(railCol, len([]rune(lines[y]))):])) == "" { + blank++ + } + } + if blank < 2 { + t.Fatalf("the rail holds %d blank rows between the pinned pair and the quiet panels, want the ordinary one and the group's own:\n%s", blank, frame) } } -// AT A HUNDRED AND EIGHTY THERE ARE THREE COLUMNS, and projects heads the third. -func TestAtOneEightyProjectsAndSpendHaveAColumnOfTheirOwn(t *testing.T) { +// AT A HUNDRED AND EIGHTY THE RAIL IS THE LAST COLUMN AND THE FIELD STILL FILLS +// FROM THE LEFT — so the rail is flush with the right edge at every width it +// exists at, and a frame whose panels fit in one column leaves the middle of the +// screen empty rather than spreading them over it. +// +// THE EMPTY MIDDLE IS THE RULING, not a gap in it (owner, 2026-09-15): the point +// of the column that has something in it standing alone at the left is that the +// eye has one place to go. +func TestAtOneEightyTheRailIsTheLastColumnAndTheFieldFillsFromTheLeft(t *testing.T) { a := newSwitchLab(t).open(180, 45) frame := homeText(a) needs, needsCol := homeRowOf(frame, "needs you") - running, runningCol := homeRowOf(frame, "running") - projects, projectsCol := homeRowOf(frame, "projects") - if needs != running || running != projects || !(needsCol < runningCol && runningCol < projectsCol) { - t.Fatalf("needs you, running and projects do not head three columns:\n%s", frame) + recent, recentCol := homeRowOf(frame, "where you were") + projects, railCol := homeRowOf(frame, "projects") + if needs != projects || needsCol >= railCol { + t.Fatalf("needs you and projects do not head the field and the rail:\n%s", frame) + } + if recent <= needs || recentCol != needsCol { + t.Fatalf("where you were is not under needs you in the first field column:\n%s", frame) + } + if needsCol != homeGridMargin { + t.Fatalf("the field does not start at the left margin (cell %d):\n%s", needsCol, frame) + } + // AND THE QUIET PANELS ARE ALL IN THE LAST COLUMN, none of them left behind + // in the field beside a panel that has something to say. + for _, word := range []string{"spend", "since you left", "next up"} { + if _, at := homeRowOf(frame, word); at != railCol { + t.Fatalf("%q is at cell %d, want the rail at %d:\n%s", word, at, railCol, frame) + } + } + // AND NOTHING AT ALL STANDS BETWEEN THEM. The middle column is the air the + // ruling spends to put the field at one edge and the rail at the other. + for at := range a.home.lines { + if got := a.home.columnOf(at); got == 1 { + t.Fatalf("line %d stands in the middle column, which this frame has nothing to put there:\n%s", at, frame) + } } } @@ -185,12 +244,20 @@ func TestATallFrameShowsTenOfWhereYouWereAndFoldsTheRest(t *testing.T) { } a.width, a.height = 120, 24 frame = homeText(a) - if len(strings.Split(frame, "\n")) != 24 || strings.Contains(frame, "66 more") { - t.Fatalf("at 120×24 the squeeze does not hold:\n%s", frame) + if len(strings.Split(frame, "\n")) != 24 { + t.Fatalf("at 120×24 the frame is not twenty-four rows:\n%s", frame) } if row, _ := homeRowOf(frame, "where you were"); row < 0 { t.Fatalf("where you were was squeezed off a 120×24 home:\n%s", frame) } + // AND A SHORT FRAME SPENDS THE RAIL BEFORE THE FIELD. Where you were is the + // only panel with rows, so it has the whole field and keeps every row it + // grew; what gives way is the column of panels with nothing in them, which + // is the trade law 2 was rewritten to make (ruled 2026-09-15). Before that + // this frame cut the recent list to share a column with two quiet panels. + if got := len(panelRows(a, panelRecent)); got != homeSlotOf(panelRecent).most { + t.Fatalf("where you were drew %d rows at 120×24, want the %d it holds alone in the field:\n%s", got, homeSlotOf(panelRecent).most, frame) + } } // AND A REAL FRAME THAT SHORT STILL DRAWS EVERY HEADING IT KEPT, with the @@ -310,13 +377,15 @@ func TestAWhisperWrapsAtItsColumnAndIsNeverCut(t *testing.T) { } // AND A REAL FRAME DRAWS THE SECOND LINE: at 120 cells a column is 58 wide, // and the end of the needs whisper is on the line under its first half, - // standing in the row's lead. + // standing in the row's lead. A quiet needs you is in the rail (law 2), so + // the lead it stands in is the rail's and not the margin's. a := newLiveLab(t).open() frame := homeText(a) - first, _ := homeRowOf(frame, "questions from any chat") + _, rail := homeRowOf(frame, "projects") + first, head := homeRowOf(frame, "questions from any chat") second, at := homeRowOf(frame, "answers them") - if first < 0 || second != first+1 || at != homeGridMargin+homeGridLead { - t.Fatalf("the needs whisper is cut rather than wrapped:\n%s", frame) + if first < 0 || second != first+1 || at != head || at != rail+homeGridLead { + t.Fatalf("the needs whisper is cut rather than wrapped in the rail:\n%s", frame) } } @@ -373,12 +442,18 @@ func TestTheFootNamesAChordWhereTheArrowCrossesColumns(t *testing.T) { if !strings.HasSuffix(opened, "beta") { t.Fatalf("ctrl+o on a project opened %q, want its folder", opened) } - a.placeKeyPress(key("right")) - if a.home.columnOf(a.home.cursor) != 1 || a.strip.open { - t.Fatal("→ on a project did not cross to the right column") + // A PROJECT IS IN THE RAIL NOW (law 2), and the rail is the last column — so + // → has no column to cross to and opens the row's verb strip instead, which + // is §6.6 read on the column the row actually ended up in. + if got := a.home.columnOf(a.home.cursor); got != homeRailCol(a.home.cols) { + t.Fatalf("a project is in column %d, want the rail at %d", got, homeRailCol(a.home.cols)) } if hint := a.homeHint(); hint != homeRestHint { - t.Fatalf("a right-column row's foot is %q, want the resting sentence", hint) + t.Fatalf("a rail row's foot is %q, want the resting sentence", hint) + } + a.placeKeyPress(key("right")) + if !a.strip.open { + t.Fatal("→ on a rail row neither crossed a column nor opened its verbs") } } diff --git a/internal/tui3/homepanel_live_test.go b/internal/tui3/homepanel_live_test.go index 7baa8526bb..2ed22ce8bc 100644 --- a/internal/tui3/homepanel_live_test.go +++ b/internal/tui3/homepanel_live_test.go @@ -451,11 +451,16 @@ func TestRunningOffersStopOnlyOnThisWindowsOwnTask(t *testing.T) { } } -// ON A THREE-COLUMN HOME `running` IS THE MIDDLE COLUMN, so `→` on one of its -// rows crosses to `projects` and `spend` rather than opening the strip — and the -// stop the strip offers keeps a door (DESIGN §6 ruling 6). At 180×45 the foot on -// a task this window holds names `ctrl+x stop it`, in the tasks place's own -// spelling of the verb ([stopActWord]), and the chord raises the stop card. +// ON A THREE-COLUMN HOME A `running` ROW IS IN THE FIELD — it has rows, and +// that is what the field is (law 2, ruled 2026-09-15) — so `→` on it crosses to +// the rail rather than opening the strip, and the stop the strip offers keeps a +// door (DESIGN §6 ruling 6). At 180×45 the foot on a task this window holds +// names `ctrl+x stop it`, in the tasks place's own spelling of the verb +// ([stopActWord]), and the chord raises the stop card. +// +// THE CROSSING SKIPS THE EMPTY MIDDLE. A field that fits in one column leaves +// the next one white, and `→` reaches the rail over it rather than stopping on +// air — a key that lands nowhere is the one state this surface may not be in. func TestAThreeColumnRunningRowNamesItsStopOnTheFoot(t *testing.T) { l := newLiveLab(t) l.live("-alpha", "aaaa000000000001", session.SessionPresence{RunningTasks: []session.PresenceTask{ @@ -468,8 +473,8 @@ func TestAThreeColumnRunningRowNamesItsStopOnTheFoot(t *testing.T) { a.tasks = map[uint64]*taskNode{5: {id: 5, state: session.TaskRunning}} homeLineOf(t, a, func(l homeLine) bool { return l.cell != nil && l.cell.panel == panelRunning && l.cell.title == "mine" }) mine := a.home.cursor - if got := a.home.columnOf(mine); got != 1 { - t.Fatalf("running stands in column %d of a three-column home, want the middle one", got) + if got := a.home.columnOf(mine); got != 0 { + t.Fatalf("running has rows and stands in column %d of a three-column home, want the field at 0", got) } if verbs := a.runningVerbs(a.home.lines[mine]); len(verbs) != 1 || verbs[0].word != stopActWord { t.Fatalf("the row's strip offers %+v, want the tasks place's `%s`", verbs, stopActWord) @@ -479,8 +484,9 @@ func TestAThreeColumnRunningRowNamesItsStopOnTheFoot(t *testing.T) { t.Fatalf("the foot on a running row this window holds is %q, want it to name `ctrl+x %s`", foot, stopActWord) } a.placeKeyPress(key("right")) - if a.strip.open || a.home.columnOf(a.home.cursor) != 2 { - t.Fatal("→ on the middle column's row did not cross to the right column") + if a.strip.open || a.home.columnOf(a.home.cursor) != homeRailCol(a.home.cols) { + t.Fatalf("→ on a field row landed in column %d (strip %v), want the rail at %d", + a.home.columnOf(a.home.cursor), a.strip.open, homeRailCol(a.home.cols)) } a.home.cursor = mine drive(t, a, key("ctrl+x")) From a95a31eeb0c49e2c462fe71fcc7f48b5be15f011 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:20:44 -0400 Subject: [PATCH 02/23] tui3: the resting box keeps a caret on the cell the first letter lands on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Home's box is the screen's one primary action (DESIGN §1 law 1), and with nothing typed into it the caret was switched off — on the reading that home at rest is a dashboard somebody reads rather than a thing they type at. The owner's reading is the other one: a box with no caret in it does not look like somewhere to type, which is the same complaint that made the box three rows tall in #1000. The hazard the old comment names is real and is answered by PLACING the caret rather than by hiding it. Unplaced it blinks at the frame's origin, over the `home` heading; it now stands on the first cell a character will land on, which is the dim sentence's own first letter — a placeholder behind a caret, as in any other text field. It is the same caret the conversation's box has: one blinking tea.CursorBar, wherever the frame puts it. The resting rows keep their empty press span, so a click on them still falls through to the place underneath (#1000). Every place's resting box gains the caret, not only home's — it is one box with one behaviour. Co-Authored-By: Claude Opus 5 --- internal/tui3/home_test.go | 40 ++++++++++++++++++++++++++++++++++++++ internal/tui3/pages.go | 20 +++++++++++++++---- 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/internal/tui3/home_test.go b/internal/tui3/home_test.go index 80fbe46bec..820e46e542 100644 --- a/internal/tui3/home_test.go +++ b/internal/tui3/home_test.go @@ -894,6 +894,46 @@ func TestHomeWithNothingTypedHangsFromTheTop(t *testing.T) { } } +// THE CARET STANDS IN THE RESTING BOX, on the first cell a character will land +// on — so the one primary action on the screen (DESIGN §1 law 1) looks like +// somewhere to type before anything has been typed into it. +// +// IT IS THE SAME CARET THE CONVERSATION HAS: [app.View] draws one blinking +// [tea.CursorBar] wherever the frame puts it, and this frame puts it behind the +// dim sentence exactly as a placeholder sits behind a caret in any text field. +// Hiding it here was the old reading — home at rest is read, not typed at — and +// the owner overturned it on 2026-09-15. +func TestTheRestingBoxKeepsACaretOnTheCellTheFirstLetterLandsOn(t *testing.T) { + a, _ := homeRestLab(t, 120) + a.caret = true + width, height := a.size() + lines, _, caretX, caretY := a.homeFrame(width, height) + if !a.caret { + t.Fatal("the resting box has no caret in it") + } + if caretY < 0 || caretY >= len(lines) { + t.Fatalf("caret row %d is outside the frame of %d rows", caretY, len(lines)) + } + row := ansi.Strip(lines[caretY]) + if !strings.Contains(row, placeRestWord) { + t.Fatalf("the caret stands on row %d %q, not on the box", caretY, row) + } + // AND ON THE SENTENCE'S FIRST LETTER, not on the prompt and not at the + // frame's origin — the two places an unplaced caret ends up. + if want := 1 + ansi.StringWidth(prompt); caretX != want { + t.Fatalf("the caret is at column %d, want %d — the cell after the prompt", caretX, want) + } + at := strings.Index(row, placeRestWord) + if at < 0 { + t.Fatalf("the resting sentence is not on the caret's row: %q", row) + } + // The column is CELLS, and `›` is three bytes of one — so the byte offset is + // measured rather than compared. + if cells := ansi.StringWidth(row[:at]); cells != caretX { + t.Fatalf("column %d is not the first letter of %q (that is at cell %d): %q", caretX, placeRestWord, cells, row) + } +} + // HOME'S RESTING FOOT, WORD FOR WORD — the two rows the design spells and the // clause it deliberately leaves out (SCREEN 1a and 2b, FIDELITY.md item 3). // diff --git a/internal/tui3/pages.go b/internal/tui3/pages.go index 1cc58626d8..5fc2b0288c 100644 --- a/internal/tui3/pages.go +++ b/internal/tui3/pages.go @@ -1386,10 +1386,22 @@ func placeFrameWithBar(a *app, width, height int, for row := 1; row < boxFloor(height); row++ { add("", nil) } - // AT REST THERE IS NOTHING TO TYPE INTO, so the caret is hidden rather - // than left blinking at the frame's origin. The moment a character lands - // the box stops being empty and the caret comes back, in the box. - a.caret = false + // AND THE CARET STANDS IN IT, on the first cell the first character will + // land on — which is the dim sentence's own first letter, exactly as a + // placeholder sits behind the caret in any other text field. It is the + // same caret the conversation's box has ([app.View] draws one + // [tea.CursorBar], blinking, wherever this lands it). + // + // IT USED TO BE HIDDEN HERE, on the reading that home at rest is a + // dashboard somebody reads rather than a thing they type at. The owner's + // reading (2026-09-15) is the other one: the box is the screen's one + // primary action (DESIGN §1 law 1), and a box with no caret in it does + // not look like somewhere to type — which is the same complaint that + // made the box three rows tall in #1000. The hazard the old comment + // names is real and is answered by placing the caret rather than by + // hiding it: unplaced, it blinks at the frame's origin over the `home` + // heading. + caretX, caretY = 1+ansi.StringWidth(prompt), boxTop } else { for i, row := range draftRows { if i == 0 { From 3f6ff3ce0b299ba8f05f3b6e5cc21f7987aa19e9 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Tue, 15 Sep 2026 12:40:45 -0400 Subject: [PATCH 03/23] tui3, demo-home: a standing row names enter only where there is a conversation to open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings from one report — `enter` on a `needs you` watch row did nothing. THE LEGEND PROMISED A DOOR THE ROW DID NOT HAVE. drawKeysBand named `enter open where it was asked` on every item, and an item with no origin has nothing to open — [app.homeItemEnter] refuses it in words. The legend's own law, stated three paragraphs above the branch that broke it, is that it names keys that work; the design's is that a capability that cannot work is absent, not broken. The clause is now drawn only where Origin.Transcript is set. THE REST WAS THE FIXTURE, twice over. Every standing order the demo seeder wrote had an empty Origin, so the demo never exercised the door at all. seed.go already keeps every conversation's id by title so that rows ABOUT a conversation name one really on disk — "a join that points at nothing draws a row a person cannot open" — and the standing orders were the one table left out of that discipline. Each now names the conversation it was asked for, and the seeder fails loudly if that title is not one it writes. And the fixture's own directory was two project buckets. On a Mac `/tmp` is a symlink to `/private/tmp`: os.MkdirTemp hands back the `/tmp` spelling, a codeaf launched inside resolves its own working directory to `/private/tmp`, and home drew the demo project twice — `…/codeaf` with the fixture's conversations and `~/codeaf` with the window's own — while the bucket guard refused every door between them. demoDir now resolves its symlinks before anything is written. Co-Authored-By: Claude Opus 5 --- cmd/codeaf-demo-home/main.go | 29 ++++++++++++++++++++---- cmd/codeaf-demo-home/seed.go | 2 +- cmd/codeaf-demo-home/seed_standing.go | 27 +++++++++++++++++++++- cmd/codeaf-demo-home/seed_test.go | 10 +++++--- internal/tui3/homeband_keys.go | 13 ++++++++++- internal/tui3/homebands_ambientb_test.go | 9 ++++++++ 6 files changed, 80 insertions(+), 10 deletions(-) diff --git a/cmd/codeaf-demo-home/main.go b/cmd/codeaf-demo-home/main.go index 47f92231c1..5403916917 100644 --- a/cmd/codeaf-demo-home/main.go +++ b/cmd/codeaf-demo-home/main.go @@ -128,13 +128,24 @@ never opened. // safe is somebody typing --into over a directory that is not a demo home at // all. So an existing directory is only accepted when it is empty, or when the // caller says --keep and the directory looks like something this program wrote. +// THE DIRECTORY IS RESOLVED THROUGH ITS SYMLINKS BEFORE ANYTHING IS WRITTEN IN +// IT, and on a Mac that is not a nicety: `/tmp` is a symlink to `/private/tmp`, +// os.MkdirTemp hands back the `/tmp` spelling, and a codeaf launched in the +// fixture resolves its own working directory to the `/private/tmp` one. The two +// spellings are two PROJECT BUCKETS for one folder — the fixture's conversations +// under `-tmp-…-codeaf` and the window's own under `-private-tmp-…-codeaf` — so +// home drew the demo project twice, and `enter` on a standing order refused +// because the conversation that asked for it was "elsewhere" +// ([app.homeItemEnter]'s bucket guard). Every path this fixture writes is the +// resolved one, so the fixture and the binary agree about what the folder is +// called. func demoDir(into string, reuse bool) (dir string, fresh bool, err error) { if into == "" { made, err := os.MkdirTemp("", "codeaf-demo-home-") if err != nil { return "", false, fmt.Errorf("make a directory to build in: %w", err) } - return made, true, nil + return resolveDemoDir(made), true, nil } dir, err = filepath.Abs(into) if err != nil { @@ -145,13 +156,13 @@ func demoDir(into string, reuse bool) (dir string, fresh bool, err error) { if err := os.MkdirAll(dir, 0o700); err != nil { return "", false, fmt.Errorf("make %s: %w", dir, err) } - return dir, true, nil + return resolveDemoDir(dir), true, nil } if err != nil { return "", false, fmt.Errorf("read %s: %w", dir, err) } if len(entries) == 0 { - return dir, true, nil + return resolveDemoDir(dir), true, nil } if !reuse { return "", false, fmt.Errorf("%s is not empty; pass --keep to reuse a demo home that is already there", dir) @@ -159,7 +170,7 @@ func demoDir(into string, reuse bool) (dir string, fresh bool, err error) { if _, err := os.Stat(filepath.Join(dir, ".codeaf", "v3", "projects")); err != nil { return "", false, fmt.Errorf("%s is not empty and does not hold a demo home", dir) } - return dir, false, nil + return resolveDemoDir(dir), false, nil } // launchAgainst runs the surface against the demo home and keeps the live @@ -208,3 +219,13 @@ func launchAgainst(dir, binary string) error { func demoEnviron() []string { return env.EnvironWithout("HOME", home.EnvVar) } + +// resolveDemoDir is the directory with its symlinks followed, and the directory +// itself where they cannot be — a path that does not resolve is not a reason to +// refuse to build a fixture in it. +func resolveDemoDir(dir string) string { + if real, err := filepath.EvalSymlinks(dir); err == nil { + return real + } + return dir +} diff --git a/cmd/codeaf-demo-home/seed.go b/cmd/codeaf-demo-home/seed.go index 6ae8f39d06..c2f762cf08 100644 --- a/cmd/codeaf-demo-home/seed.go +++ b/cmd/codeaf-demo-home/seed.go @@ -115,7 +115,7 @@ func seedDemoHome(dir string, now time.Time) (builtHome, error) { } built.Nodes, built.Jobs = nodes, jobs - orders, err := writeStanding(filepath.Join(root, "v3", "standing"), projects, now) + orders, err := writeStanding(filepath.Join(root, "v3", "standing"), projects, ids, now) if err != nil { return built, err } diff --git a/cmd/codeaf-demo-home/seed_standing.go b/cmd/codeaf-demo-home/seed_standing.go index 18fcaf176f..dc49b438b6 100644 --- a/cmd/codeaf-demo-home/seed_standing.go +++ b/cmd/codeaf-demo-home/seed_standing.go @@ -13,6 +13,7 @@ package main import ( "fmt" + "path/filepath" "time" "github.com/Agent-Field/codeaf/internal/standing" @@ -27,6 +28,16 @@ type demoOrder struct { // as the ticker applies it, because Create refuses to make anything that is // not active and has never run. after func(item *standing.Item, now time.Time) + // asked is the TITLE of the conversation this order was made in, and it + // becomes the item's [standing.Origin] — the door home opens on `enter`, + // which answers "why did I get this?". + // + // EVERY ORDER NEEDS ONE. An item with no origin is a row whose `enter` + // refuses, and a fixture full of them is a fixture that never exercises the + // door at all — which is the join discipline seed.go already states for + // tasks and ledger lines: a join that points at nothing draws a row a person + // cannot open. + asked string // spent is the ledger this order has left behind: one entry per element, // oldest first, counted back in days from today. spent []demoFiring @@ -52,6 +63,7 @@ var demoOrders = []demoOrder{ // The one that is ASKING. Its latest run stopped on a question, which is // the field home sorts on and the reason this row is drawn first. project: firstProjectName, + asked: "Standing Up the Watches", item: standing.Item{ ID: "watch-ci", Words: "tell me when CI goes red on master", @@ -82,6 +94,7 @@ var demoOrders = []demoOrder{ // The one that FIRED TODAY and has come back clean three times running, // which is the count the rope column's middle rung is drawn from. project: firstProjectName, + asked: "Standing Up the Watches", item: standing.Item{ ID: "morning-sweep", Words: "sweep the repo every morning at nine and tell me what changed under me", @@ -118,6 +131,7 @@ var demoOrders = []demoOrder{ // card in a conversation and only a yes makes an item — so the pause is a // Save afterwards, exactly as the surface's own `p` writes it. project: "infra", + asked: "The Backup Window", item: standing.Item{ ID: "deps-weekly", Words: "check the dependency advisories every Monday morning", @@ -140,6 +154,7 @@ var demoOrders = []demoOrder{ // The RULE. A hold never wakes, so it can never spend, which is why it // alone needs no rails and no action — and why it leaves no ledger line. project: firstProjectName, + asked: "The Manual's Missing Page", item: standing.Item{ ID: "public-api-hold", Words: "never change the public API without telling me first", @@ -152,7 +167,7 @@ var demoOrders = []demoOrder{ // writeStanding creates the four orders, writes the quiet half onto them, and // leaves the day ledgers their cost-per-firing is summed from. -func writeStanding(root string, projects map[string]*demoProject, now time.Time) (int, error) { +func writeStanding(root string, projects map[string]*demoProject, ids map[string]string, now time.Time) (int, error) { orders, err := standing.Open(root) if err != nil { return 0, fmt.Errorf("open the standing store: %w", err) @@ -165,6 +180,16 @@ func writeStanding(root string, projects map[string]*demoProject, now time.Time) } item := order.item item.Workspace = project.dir + // THE ORDER REMEMBERS WHERE IT WAS ASKED FOR, so `enter` on its home row + // has a conversation to open ([app.homeItemEnter]). + id, ok := ids[order.asked] + if !ok { + return written, fmt.Errorf("the standing order %q was asked for in %q, which is not a conversation this fixture writes", item.ID, order.asked) + } + item.Origin = standing.Origin{ + SessionID: id, + Transcript: filepath.Join(project.bucket, id, "transcript.jsonl"), + } made, err := orders.Create(item) if err != nil { return written, fmt.Errorf("create the standing order %q: %w", item.ID, err) diff --git a/cmd/codeaf-demo-home/seed_test.go b/cmd/codeaf-demo-home/seed_test.go index ed089eb4cf..4bfd7ddb07 100644 --- a/cmd/codeaf-demo-home/seed_test.go +++ b/cmd/codeaf-demo-home/seed_test.go @@ -364,11 +364,15 @@ func TestTheDemoHomeRefusesADirectoryThatIsNotItsOwn(t *testing.T) { } // An empty directory, and one this program has already built, are the two it - // will take. + // will take — and what comes back is the directory with its SYMLINKS + // FOLLOWED. On a Mac t.TempDir() is under `/var`, which is a symlink to + // `/private/var`, and the fixture has to write the spelling a codeaf launched + // inside it will resolve for itself or the two disagree about what the + // project folder is called ([resolveDemoDir]). empty := t.TempDir() dir, fresh, err := demoDir(empty, false) - if err != nil || !fresh || dir != empty { - t.Fatalf("an empty directory came back as (%q, fresh=%v, %v)", dir, fresh, err) + if err != nil || !fresh || dir != resolveDemoDir(empty) { + t.Fatalf("an empty directory came back as (%q, fresh=%v, %v), want %q", dir, fresh, err, resolveDemoDir(empty)) } if _, err := seedDemoHome(empty, time.Now()); err != nil { t.Fatal(err) diff --git a/internal/tui3/homeband_keys.go b/internal/tui3/homeband_keys.go index fdf66215e7..636c253738 100644 --- a/internal/tui3/homeband_keys.go +++ b/internal/tui3/homeband_keys.go @@ -1,5 +1,7 @@ package tui3 +import "strings" + func init() { registerHomeBand(homeBand{name: "keys", order: bandOrderKeys, kinds: []bandKind{bandKindSession, bandKindItem}, draw: drawKeysBand}) @@ -34,7 +36,16 @@ func drawKeysBand(a *app, ctx bandContext) []string { } clauses = []string{"enter open", "ctrl+t new chat here", "ctrl+o open folder", "ctrl+y copy path", aside, "→ more"} case bandKindItem: - clauses = []string{"enter open where it was asked", "ctrl+e pause", "ctrl+x stop"} + clauses = []string{"ctrl+e pause", "ctrl+x stop"} + // AND `enter` ONLY WHERE THERE IS A CONVERSATION BEHIND THE ITEM. An item + // made at home has no origin to open ([app.homeItemEnter] refuses it in + // words), so naming the key here would advertise a door this row has + // already decided it does not have — which is this legend's own law, said + // three paragraphs up about a conversation whose folder is gone, and the + // design's: A CAPABILITY THAT CANNOT WORK IS ABSENT, NOT BROKEN. + if strings.TrimSpace(ctx.subject.item.Item.Origin.Transcript) != "" { + clauses = append([]string{homeItemEnterWord}, clauses...) + } // AND THE RUNG'S CHORD ONLY WHERE THERE IS A DOOR TO MOVE IT THROUGH, // which is the same rule the three above are drawn under — a read-only // window keeps every key that asks nothing of the disk and loses the ones diff --git a/internal/tui3/homebands_ambientb_test.go b/internal/tui3/homebands_ambientb_test.go index 222a996493..e31e7a8ef6 100644 --- a/internal/tui3/homebands_ambientb_test.go +++ b/internal/tui3/homebands_ambientb_test.go @@ -77,7 +77,16 @@ func TestKeysBandDrawsBothLegendsAndObeysWidth(t *testing.T) { t.Fatalf("session keys = %q", got) } assertNarrowRows(t, "keys", drawKeysBand(a, ambientBandContextAt(a, 30, time.Now())), 30, "→ more") + // AN ITEM NAMES `enter` ONLY WHERE THERE IS A CONVERSATION BEHIND IT. The + // legend's own law is that it names keys that work, and an item made at home + // has no origin to open — [app.homeItemEnter] refuses it in words, so a + // legend that advertised the key would be inviting a keystroke the row has + // already decided against. ctx.subject.kind = bandKindItem + if got := plain(drawKeysBand(a, ctx)[0]); strings.Contains(got, "enter") { + t.Fatalf("an item with no origin names enter: %q", got) + } + ctx.subject.item.Item.Origin.Transcript = "/work/.codeaf/v3/projects/-work/s1/transcript.jsonl" if got := plain(drawKeysBand(a, ctx)[0]); !strings.HasPrefix(got, "enter open where") { t.Fatalf("item keys = %q", got) } From af00d8654825a97972ae45af62b38b9bdc268cee Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Tue, 15 Sep 2026 13:15:26 -0400 Subject: [PATCH 04/23] tui3: the middle column is what the selected row says about itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The field spilled into the middle column when it ran out of vertical room, so the middle was a panel sometimes and blank other times — and on the frame a person actually has, a tall terminal and a quiet machine, it was blank always. THE FIELD IS NOW ONE COLUMN AT EVERY WIDTH and what will not fit in it folds, which is the squeeze that has always run at the widths with no second column to spill into. That buys a middle column that means one thing at every size: the description of whichever row is selected, wrapped over as many lines as it needs, with the row's answers under it. It is assembled at PAINT time and not at build time, which is the whole reason it can follow the cursor: the grid's lines are settled when the room or the width moves and an arrow key moves neither. It follows the pointer through the same previewLine the card beside the search uses, and it starts ON THE ROW IT IS ABOUT — a sentence at the top of a column while its row is eight lines down is a sentence whose owner a person has to work out. Nothing in the column is a stop, so `→` steps over it to the rail rather than parking the cursor on a sentence about the row it just left. A ROW IS THEREFORE ONE LINE, and the panel neither draws the second line nor reserves the room for it. Both kinds move: the needs-you question that was always drawn, and the sentence a to-check landing grew only under the cursor. At two columns and at one there is no middle, and every row keeps its own second line exactly as before. Co-Authored-By: Claude Opus 5 --- internal/tui3/home.go | 13 ++++++ internal/tui3/homecell.go | 75 ++++++++++++++++++++++++++++++++- internal/tui3/homegrid.go | 76 ++++++++++++++++++---------------- internal/tui3/homegrid_test.go | 69 +++++++++++++++++++++++++----- 4 files changed, 187 insertions(+), 46 deletions(-) diff --git a/internal/tui3/home.go b/internal/tui3/home.go index 5672e4942b..58067662f2 100644 --- a/internal/tui3/home.go +++ b/internal/tui3/home.go @@ -2262,6 +2262,19 @@ func (h *homeView) previewLine() (homeLine, bool) { return h.focusedLine() } +// previewAt is [homeView.previewLine]'s line NUMBER, for the readers that need +// to find where that line was drawn rather than what it holds +// ([homeDescTop]). +func (h *homeView) previewAt() int { + if h.hover >= 0 && h.hover < len(h.lines) && h.lines[h.hover].stop() { + return h.hover + } + if _, ok := h.focusedLine(); !ok { + return homeNoLine + } + return h.cursor +} + // point puts the cursor on the row holding a transcript, and leaves it where it // is when that conversation is not on the list any more. func (h *homeView) point(transcript string) { diff --git a/internal/tui3/homecell.go b/internal/tui3/homecell.go index 1819648ca9..1ddc240e45 100644 --- a/internal/tui3/homecell.go +++ b/internal/tui3/homecell.go @@ -56,6 +56,9 @@ func (a *app) homeGridRows(width, room int, pal palette) []placeRow { c := min(h.grid.col[at], len(xs)-1) columns[c] = append(columns[c], a.homeLineRows(line, at, widths[c], pal, h.marksPanel(at))...) } + if at := homeDescCol(h.grid.cols); at != homeNoLine && at < len(columns) { + columns[at] = a.homeDescLines(widths[at], room, pal, homeDescTop(columns, a.home.previewAt())) + } rows := make([]placeRow, room) for y := range rows { rows[y] = homeGridZip(columns, y, xs) @@ -63,6 +66,59 @@ func (a *app) homeGridRows(width, room int, pal palette) []placeRow { return rows } +// homeDescLines is the middle column: what the SELECTED ROW says about itself, +// wrapped, and nothing at all where the row has nothing to say. +// +// IT IS BUILT AT PAINT TIME AND NOT AT BUILD TIME, which is the whole reason it +// can follow the cursor: the grid's lines are settled when the room or the width +// moves ([homeView.buildGrid]) and an arrow key moves neither, so a column +// assembled up there would answer about whichever row the cursor happened to be +// on when the frame was last rebuilt. +// +// IT FOLLOWS THE POINTER TOO, through the same [homeView.previewLine] the card +// beside the search has always used — hovering a row reads that row here without +// moving the cursor, and a pointer that leaves the column gives the description +// back to the cursor's row. +// +// NOTHING IN IT IS A STOP. The column holds no row a cursor may stand on, which +// is what makes `→` step over it to the rail ([homeView.gridCrossTarget]) rather +// than parking the cursor on a sentence about the row it just left. +func (a *app) homeDescLines(width, room int, pal palette, top int) []homeCellLine { + line, ok := a.home.previewLine() + if !ok || line.cell == nil || room <= 0 { + return nil + } + said := strings.TrimSpace(line.cell.sub) + if said == "" { + return nil + } + // IT STARTS ON THE ROW IT IS ABOUT. A sentence at the top of a column while + // the row it describes is eight lines down is a sentence a person has to + // work out the owner of; on the row's own line the two read as one thing, + // which is what the second line under the row did for free. + out := make([]homeCellLine, 0, room) + for y := 0; y < top && y < room; y++ { + out = append(out, homeCellLine{at: homeNoLine, head: -1}) + } + // The lead is the rows' own, so the sentence starts in the column every + // title on the screen starts in. + for _, words := range wrap(said, max(1, width-homeGridLead)) { + if len(out) >= room { + break + } + out = append(out, homeCellLine{at: homeNoLine, head: -1, text: homeCellLeadBlank + pal.dim(words)}) + } + // AND THE ANSWERS UNDER IT, on a line of their own — they were at the right + // of the row's second line, and that line is here now. + if answers := strings.TrimSpace(a.homeRowAnswers(line, a.home.cursor)); answers != "" && len(out) < room { + out = append(out, homeCellLine{at: homeNoLine, head: -1}) + if len(out) < room { + out = append(out, homeCellLine{at: homeNoLine, head: -1, text: homeCellLeadBlank + paintHint(answers, pal, pal.dim)}) + } + } + return out +} + // homeGridZip is one body row: each column's row at its x, and the mark that // says which line of the list each column drew there. func homeGridZip(columns [][]homeCellLine, y int, xs []int) placeRow { @@ -282,7 +338,9 @@ func (a *app) homeCellRow(line homeLine, at, width int, pal palette, lit bool) [ 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 == "" || (cell.grows && at != a.home.cursor) { + // THE DESCRIPTION COLUMN HAS THIS LINE WHERE THERE IS ONE, so the row is one + // line and the panel above it is that much shorter ([homeDescCol]). + if cell.sub == "" || homeDescOn(a.home.grid.cols) || (cell.grows && at != a.home.cursor) { return rows } under := switcherSides(max(1, width-homeGridLead), cell.sub, a.homeRowAnswers(line, at), pal.dim, pal.muted) @@ -454,3 +512,18 @@ func homeCellWidth(title string, pad int, note, tag, right string) int { } return n } + +// homeDescTop is the body row the described line was drawn on, so the +// description can start there. It is the FIELD's own column that is searched, +// because that is the only one whose rows have descriptions. +func homeDescTop(columns [][]homeCellLine, at int) int { + if at == homeNoLine || len(columns) == 0 { + return 0 + } + for y, line := range columns[0] { + if line.at == at { + return y + } + } + return 0 +} diff --git a/internal/tui3/homegrid.go b/internal/tui3/homegrid.go index a6e7a54f54..899526c941 100644 --- a/internal/tui3/homegrid.go +++ b/internal/tui3/homegrid.go @@ -512,6 +512,10 @@ type homeGridPanel struct { // gone from the page. shown int dropped bool + // desc is that this frame draws the description column, which changes what + // a ROW is: its second line is drawn there instead of under it, so the panel + // neither draws that line nor reserves the room for it ([homeDescOn]). + desc bool // gapAbove is a second blank row over this panel's heading. It is set on the // first quiet panel of the rail, so the pinned pair at the top is read as a // group that lives there and the panels under it as the ones that are only @@ -551,6 +555,12 @@ func (p homeGridPanel) height() int { } n := 1 for _, line := range p.read.lines[:p.shown] { + // A ROW IS ONE LINE WHERE THE DESCRIPTION COLUMN HAS ITS SECOND, and the + // reservation below goes with it. + if p.desc { + n++ + continue + } n += line.height() } // A GROUP'S LINE AND THE AIR OVER IT ARE THE PANEL'S ROWS TOO, drawn only @@ -565,7 +575,7 @@ func (p homeGridPanel) height() int { // 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() { + if p.growsARow() && !p.desc { n++ } if p.folds() { @@ -749,39 +759,32 @@ func (p *homeGridPanel) shrink() { // order, each in the column its own content puts it in ([homeColumnOf]), at // that column's width. // -// THE FIELD IS EVERY COLUMN LEFT OF THE RAIL, AND IT FILLS FROM THE LEFT. A -// panel with rows takes the first field column its height still fits in, and -// the next only when that one is full — so the first panel with rows is at the -// top left corner at every width, and a frame with one column's worth of -// content leaves the middle of the screen empty rather than spreading two short -// columns over it. +// THE FIELD IS ONE COLUMN AT EVERY WIDTH, and what will not fit in it FOLDS +// rather than spilling sideways — the same squeeze that has always run at the +// widths with no second column to spill into (law 5). So the first panel with +// rows is at the top left corner always, and the cursor's panel is in column +// zero always, which is what keeps `↓` off the tab bar landing on the same line +// at every width ([homeView.placesTop], homebridge.go): a landing that moved +// with the frame is a landing nobody can build a habit on. // -// IT DOES NOT BALANCE THE COLUMNS, and that is the point rather than a -// shortcoming. Balancing put whichever panel the cursor was resting in into -// whichever column had room, so `↓` off the tab bar landed on a different panel -// at 120 cells than at 200 — and the landing is the same line at every width -// ([homeView.placesTop], homebridge.go), because a landing that moves with the -// frame is a landing nobody can build a habit on. Filling from the left keeps -// the cursor's panel in the same column at every width the content fits in. +// AND THE COLUMN IT NO LONGER SPILLS INTO IS THE DESCRIPTION'S (owner, +// 2026-09-15). A field that could take the middle when it ran out of room made +// the middle a column that was a description sometimes and a panel other times; +// reserving it costs a squeeze on a short frame and buys a column that means one +// thing at every size ([homeDescCol]). func homeGridLayout(in *homeGridInput, cols, width, room int) [][]*homeGridPanel { columns := make([][]*homeGridPanel, cols) _, widths := homeGridGeometry(width, cols) rail := homeRailCol(cols) - field := make([]int, max(1, rail)) for _, slot := range homePanelOrder { read := slot.panel.rows(in) p := &homeGridPanel{slot: slot, read: read} p.shown = p.natural() + p.desc = homeDescOn(cols) at := homeColumnOf(slot, cols, p.empty()) - if at != rail { - at = fillField(field, p.height(), room) - } if p.empty() { p.whisper = homeWhisperLines(slot.panel.whisper(), widths[at]) } - if at != rail { - field[at] += p.height() + 1 - } columns[at] = append(columns[at], p) } // ONE COLUMN HAS NO RAIL AND THEREFORE NO GROUPS IN IT. Every panel is in @@ -798,23 +801,26 @@ func homeGridLayout(in *homeGridInput, cols, width, room int) [][]*homeGridPanel return columns } -// fillField is the field column a panel this tall goes in: the first from the -// left with the room for it, and the last when none has ([homeGridLayout] says -// why it fills rather than balances). A frame with no room at all — the build -// before the first one knows a height — keeps everything in the first column, -// where the squeeze will find it. -func fillField(field []int, height, room int) int { - if room <= 0 { - return 0 - } - for at, filled := range field { - if filled == 0 || filled+height <= room { - return at - } +// homeDescCol is the column the selected row's description stands in, and +// [homeNoLine] where this frame has no room for one. +// +// IT IS THE MIDDLE COLUMN AND ONLY EXISTS AT THREE (owner, 2026-09-15). Two +// columns are the field and the rail with nothing between them, and one column +// is the whole screen read top to bottom — so at both of those a row keeps its +// description under itself, exactly as every row did before this column existed. +func homeDescCol(cols int) int { + if !homeDescOn(cols) { + return homeNoLine } - return len(field) - 1 + return 1 } +// homeDescOn reports that this frame draws the description column, which is the +// question the ROW asks: a row whose description is drawn elsewhere does not +// draw it under itself, and does not reserve the line for it either +// ([homeGridPanel.height]). +func homeDescOn(cols int) bool { return cols >= 3 } + // orderRail puts the pinned panels at the top of the rail and the quiet ones // under them, each group in table order. THE TOP OF THE RAIL IS THE PART THAT // DOES NOT MOVE: projects and spend are there on every machine on every day, so diff --git a/internal/tui3/homegrid_test.go b/internal/tui3/homegrid_test.go index 8b0324896d..26c20c22f5 100644 --- a/internal/tui3/homegrid_test.go +++ b/internal/tui3/homegrid_test.go @@ -122,15 +122,16 @@ func TestTheRailKeepsTheQuietPanelsAnExtraRowBelowThePinnedPair(t *testing.T) { } } -// AT A HUNDRED AND EIGHTY THE RAIL IS THE LAST COLUMN AND THE FIELD STILL FILLS -// FROM THE LEFT — so the rail is flush with the right edge at every width it -// exists at, and a frame whose panels fit in one column leaves the middle of the -// screen empty rather than spreading them over it. +// AT A HUNDRED AND EIGHTY THE FIELD IS THE FIRST COLUMN, THE RAIL IS THE LAST, +// AND THE MIDDLE BELONGS TO THE SELECTED ROW'S DESCRIPTION — so the rail is +// flush with the right edge at every width it exists at, and no panel is ever +// drawn between them. // -// THE EMPTY MIDDLE IS THE RULING, not a gap in it (owner, 2026-09-15): the point -// of the column that has something in it standing alone at the left is that the -// eye has one place to go. -func TestAtOneEightyTheRailIsTheLastColumnAndTheFieldFillsFromTheLeft(t *testing.T) { +// THE FIELD NEVER SPILLS SIDEWAYS (owner, 2026-09-15). What will not fit in one +// column folds, the way it always has at the widths with no second column, so +// the middle means one thing at every size instead of being a description +// sometimes and a panel other times. +func TestAtOneEightyTheFieldIsOneColumnAndTheMiddleIsNoPanels(t *testing.T) { a := newSwitchLab(t).open(180, 45) frame := homeText(a) needs, needsCol := homeRowOf(frame, "needs you") @@ -155,12 +156,60 @@ func TestAtOneEightyTheRailIsTheLastColumnAndTheFieldFillsFromTheLeft(t *testing // AND NOTHING AT ALL STANDS BETWEEN THEM. The middle column is the air the // ruling spends to put the field at one edge and the rail at the other. for at := range a.home.lines { - if got := a.home.columnOf(at); got == 1 { - t.Fatalf("line %d stands in the middle column, which this frame has nothing to put there:\n%s", at, frame) + if got := a.home.columnOf(at); got == homeDescCol(a.home.cols) { + t.Fatalf("line %d stands in the description column, which no panel may be drawn in:\n%s", at, frame) } } } +// THE MIDDLE COLUMN IS WHAT THE SELECTED ROW SAYS ABOUT ITSELF, on the row's own +// line — and the row is one line, because the line it used to grow is over there +// now. +func TestTheDescriptionColumnCarriesTheSelectedRowsOwnSentence(t *testing.T) { + lab := newAnswerLab(t, consentQuestion(7, "needs your ok to run bash"), time.Now()) + a := lab.a + a.width, a.height = 180, 45 + homeText(a) + homeLineOf(t, a, func(l homeLine) bool { + return l.cell != nil && l.cell.panel == panelNeeds && strings.TrimSpace(l.cell.sub) != "" + }) + line, _ := a.home.focusedLine() + said := strings.TrimSpace(line.cell.sub) + frame := homeText(a) + + // THE SENTENCE IS IN THE MIDDLE COLUMN and not under its row. + row, at := homeRowOf(frame, firstWordsOf(said)) + if row < 0 { + t.Fatalf("the selected row's sentence %q is nowhere on the frame:\n%s", said, frame) + } + _, field := homeRowOf(frame, "needs you") + _, rail := homeRowOf(frame, "projects") + if at <= field || at >= rail { + t.Fatalf("the sentence is at cell %d, want it between the field at %d and the rail at %d:\n%s", at, field, rail, frame) + } + // AND IT STARTS ON THE ROW IT IS ABOUT, so the two read as one thing. + title, _ := homeRowOf(frame, line.cell.title) + if title != row { + t.Fatalf("the sentence is on row %d and its row is on %d:\n%s", row, title, frame) + } + // AND THE ROW ITSELF IS ONE LINE: the line under it is another row, not its + // own second line. + lines := strings.Split(frame, "\n") + if title+1 < len(lines) && strings.Contains(lines[title+1], firstWordsOf(said)) { + t.Fatalf("the row still draws its own second line:\n%s", frame) + } +} + +// firstWordsOf is enough of a sentence to find it on a frame that may have +// wrapped the rest of it. +func firstWordsOf(said string) string { + words := strings.Fields(said) + if len(words) > 4 { + words = words[:4] + } + return strings.Join(words, " ") +} + // A SHORT TERMINAL SQUEEZES IN PRIORITY ORDER (law 5): next up gives way first, // then spend, and a squeezed panel keeps its heading and its fold. func TestAShortColumnShrinksTheLowestPriorityPanelFirst(t *testing.T) { From 2bd4439f1501ec836b16cac3093d035077c2440a Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:28:26 -0400 Subject: [PATCH 05/23] tui3: needs you keeps its question under its row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Everywhere else a row's second line is a GLOSS on the row — what a piece of work is doing, the first sentence of a report — and a gloss is exactly what a column about the selected row is for. On `needs you` the second line IS the row: the question is the thing that stopped, and the panel's whole purpose is that a person reads what is waiting on them without walking the cursor onto it. A question you have to select to read is a question you can miss. So a permanent sub on `needs you` stays under its row, keeps its reserved line in the panel's height, and the description column draws nothing for it — the same words twice on one frame is the reader wondering which is which. The `to check` landings in the same panel grow their sentence under the cursor and have never been readable at a glance, so they move to the column like every other row's. Co-Authored-By: Claude Opus 5 --- internal/tui3/homecell.go | 7 ++++-- internal/tui3/homegrid.go | 23 ++++++++++++++++-- internal/tui3/homegrid_test.go | 43 +++++++++++++++++++++++++++------- 3 files changed, 61 insertions(+), 12 deletions(-) diff --git a/internal/tui3/homecell.go b/internal/tui3/homecell.go index 1ddc240e45..f322002e94 100644 --- a/internal/tui3/homecell.go +++ b/internal/tui3/homecell.go @@ -89,7 +89,10 @@ func (a *app) homeDescLines(width, room int, pal palette, top int) []homeCellLin return nil } said := strings.TrimSpace(line.cell.sub) - if said == "" { + // AND NOTHING AT ALL FOR A ROW THAT KEPT ITS SENTENCE. The column would be + // saying a second time what the row under the cursor is already saying, and + // the same words twice on one frame is the reader wondering which is which. + if said == "" || line.cell.keepsSub() { return nil } // IT STARTS ON THE ROW IT IS ABOUT. A sentence at the top of a column while @@ -340,7 +343,7 @@ func (a *app) homeCellRow(line homeLine, at, width int, pal palette, lit bool) [ rows := []string{homeCellBand(a.homeCellLead(cell, at, pal)+body, width, pal, lit)} // THE DESCRIPTION COLUMN HAS THIS LINE WHERE THERE IS ONE, so the row is one // line and the panel above it is that much shorter ([homeDescCol]). - if cell.sub == "" || homeDescOn(a.home.grid.cols) || (cell.grows && at != a.home.cursor) { + if cell.sub == "" || (homeDescOn(a.home.grid.cols) && !cell.keepsSub()) || (cell.grows && at != a.home.cursor) { return rows } under := switcherSides(max(1, width-homeGridLead), cell.sub, a.homeRowAnswers(line, at), pal.dim, pal.muted) diff --git a/internal/tui3/homegrid.go b/internal/tui3/homegrid.go index 899526c941..841242bdc2 100644 --- a/internal/tui3/homegrid.go +++ b/internal/tui3/homegrid.go @@ -492,6 +492,24 @@ func (l homeLine) height() int { return 1 } +// keepsSub reports a row that draws its own second line even where the +// description column exists ([homeDescOn]). +// +// `needs you` IS THE ONE EXCEPTION, and the owner made it one on 2026-09-15. +// Everywhere else the second line is a gloss on the row — what a piece of work +// is doing, the first sentence of a report — and a gloss is exactly what a +// column about the selected row is for. On `needs you` the second line IS the +// row: the question is the thing that stopped, and the panel's whole purpose is +// that a person reads what is waiting on them WITHOUT having to walk the cursor +// onto it. A question you must select to read is a question you can miss. +// +// It is the permanent one only. A `to check` landing in the same panel grows its +// sentence under the cursor and has never been readable at a glance, so it moves +// to the column like every other row's. +func (c *homeCell) keepsSub() bool { + return c != nil && c.panel == panelNeeds && !c.grows +} + // ── the layout ───────────────────────────────────────────────────────────── // homeGrid is the shape the last build settled: how many columns, and which @@ -556,8 +574,9 @@ func (p homeGridPanel) height() int { n := 1 for _, line := range p.read.lines[:p.shown] { // A ROW IS ONE LINE WHERE THE DESCRIPTION COLUMN HAS ITS SECOND, and the - // reservation below goes with it. - if p.desc { + // reservation below goes with it — unless it is a row that keeps its own + // ([homeCell.keepsSub]). + if p.desc && !line.cell.keepsSub() { n++ continue } diff --git a/internal/tui3/homegrid_test.go b/internal/tui3/homegrid_test.go index 26c20c22f5..fbecd4f63e 100644 --- a/internal/tui3/homegrid_test.go +++ b/internal/tui3/homegrid_test.go @@ -166,12 +166,10 @@ func TestAtOneEightyTheFieldIsOneColumnAndTheMiddleIsNoPanels(t *testing.T) { // line — and the row is one line, because the line it used to grow is over there // now. func TestTheDescriptionColumnCarriesTheSelectedRowsOwnSentence(t *testing.T) { - lab := newAnswerLab(t, consentQuestion(7, "needs your ok to run bash"), time.Now()) - a := lab.a - a.width, a.height = 180, 45 + a := newSwitchLab(t).open(180, 45) homeText(a) homeLineOf(t, a, func(l homeLine) bool { - return l.cell != nil && l.cell.panel == panelNeeds && strings.TrimSpace(l.cell.sub) != "" + return l.cell != nil && strings.TrimSpace(l.cell.sub) != "" && !l.cell.keepsSub() }) line, _ := a.home.focusedLine() said := strings.TrimSpace(line.cell.sub) @@ -182,10 +180,9 @@ func TestTheDescriptionColumnCarriesTheSelectedRowsOwnSentence(t *testing.T) { if row < 0 { t.Fatalf("the selected row's sentence %q is nowhere on the frame:\n%s", said, frame) } - _, field := homeRowOf(frame, "needs you") - _, rail := homeRowOf(frame, "projects") - if at <= field || at >= rail { - t.Fatalf("the sentence is at cell %d, want it between the field at %d and the rail at %d:\n%s", at, field, rail, frame) + _, rail := homeRowOf(frame, "projects · ") + if at <= homeGridMargin || at >= rail { + t.Fatalf("the sentence is at cell %d, want it between the field at %d and the rail at %d:\n%s", at, homeGridMargin, rail, frame) } // AND IT STARTS ON THE ROW IT IS ABOUT, so the two read as one thing. title, _ := homeRowOf(frame, line.cell.title) @@ -200,6 +197,36 @@ func TestTheDescriptionColumnCarriesTheSelectedRowsOwnSentence(t *testing.T) { } } +// `needs you` KEEPS ITS QUESTION UNDER ITS ROW even where the description column +// exists, and the column does not repeat it. Everywhere else the second line is +// a gloss; here it is the row — a question you have to select to read is a +// question you can miss (owner, 2026-09-15). +func TestTheNeedsYouQuestionStaysUnderItsRowAndIsNotRepeatedInTheColumn(t *testing.T) { + lab := newAnswerLab(t, consentQuestion(7, "needs your ok to run bash"), time.Now()) + a := lab.a + a.width, a.height = 180, 45 + homeText(a) + homeLineOf(t, a, func(l homeLine) bool { + return l.cell != nil && l.cell.panel == panelNeeds && strings.TrimSpace(l.cell.sub) != "" + }) + line, _ := a.home.focusedLine() + said := strings.TrimSpace(line.cell.sub) + if !line.cell.keepsSub() { + t.Fatalf("a needs you question does not keep its own line") + } + frame := homeText(a) + lines := strings.Split(frame, "\n") + title, _ := homeRowOf(frame, line.cell.title) + if title < 0 || title+1 >= len(lines) || !strings.Contains(lines[title+1], firstWordsOf(said)) { + t.Fatalf("the question is not on the line under its row:\n%s", frame) + } + // AND ONCE ONLY. The column saying it a second time is the same words twice + // on one frame. + if n := strings.Count(frame, firstWordsOf(said)); n != 1 { + t.Fatalf("the question is on the frame %d times, want once:\n%s", n, frame) + } +} + // firstWordsOf is enough of a sentence to find it on a frame that may have // wrapped the rest of it. func firstWordsOf(said string) string { From d62265a85d31f8e65c14aff8dde1b28da20eec15 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:46:36 -0400 Subject: [PATCH 06/23] tui3: the description column is every row's note, aligned to its row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things the owner asked for, and they are one mechanism. `enter` ON A NEEDS YOU ROW IS NAMED ONLY WHERE IT WORKS. `subRight` was set on every row of the panel, so a watch made from home's own box — whose origin is an EXCHANGE under the item's folder rather than a session ([standing.Origin]) — drew `enter` beside a question while [app.homeItemEnter] answered `made from home — no conversation to open`. A conversation's question always has a door and keeps the key; a watch has one only where it was asked for in a conversation. This is the legend's law said on the row itself: NAME ONLY KEYS THAT WORK. THE NEEDS YOU QUESTION IS IN THE COLUMN NOW, AND ALWAYS. It was inline as the one exception; it is instead the one row whose note is drawn whether or not the row is selected ([homeCell.alwaysSaid]), because the panel exists so a person reads what is waiting on them without walking the cursor onto it. Every row is one line again. WHICH WORKS BECAUSE EVERY NOTE STANDS ON ITS OWN ROW'S LINE. Which row a sentence belongs to is a fact about where it is rather than something the reader works out, so a permanent note and the selected row's can share the column without either being ambiguous; a note runs down only as far as the next note's row, so two never overlap. AND TWO MORE PANELS HAVE SOMETHING TO SAY. A `where you were` row draws the last thing this person said in that conversation — the one fact that tells two conversations with similar names apart, and the reading this window's own row has always drawn. It is asked for THE ROW BEING READ and cached, off the draw: a screen that read forty journals to fill a column about one row would be paying for thirty-nine nobody looked at. A `next up` row draws the arrangement in the person's own words, and nothing where those words are already the row's title. Both are carried only on a frame that HAS the column. A sub reserves a line in its panel's height, and paying that on a narrow terminal for a column that is not on the screen costs a row a person could have read. Co-Authored-By: Claude Opus 5 --- internal/tui3/homecell.go | 132 ++++++++++++++++++------------ internal/tui3/homegrid.go | 43 ++++++---- internal/tui3/homegrid_test.go | 42 ++++++---- internal/tui3/homepanel_needs.go | 16 +++- internal/tui3/homepanel_next.go | 26 +++++- internal/tui3/homepanel_recent.go | 9 ++ 6 files changed, 186 insertions(+), 82 deletions(-) diff --git a/internal/tui3/homecell.go b/internal/tui3/homecell.go index f322002e94..268ccccc26 100644 --- a/internal/tui3/homecell.go +++ b/internal/tui3/homecell.go @@ -57,7 +57,7 @@ func (a *app) homeGridRows(width, room int, pal palette) []placeRow { columns[c] = append(columns[c], a.homeLineRows(line, at, widths[c], pal, h.marksPanel(at))...) } if at := homeDescCol(h.grid.cols); at != homeNoLine && at < len(columns) { - columns[at] = a.homeDescLines(widths[at], room, pal, homeDescTop(columns, a.home.previewAt())) + columns[at] = a.homeDescLines(widths[at], room, pal, columns[0]) } rows := make([]placeRow, room) for y := range rows { @@ -66,8 +66,18 @@ func (a *app) homeGridRows(width, room int, pal palette) []placeRow { return rows } -// homeDescLines is the middle column: what the SELECTED ROW says about itself, -// wrapped, and nothing at all where the row has nothing to say. +// homeDescLines is the middle column: what the rows have to say about +// themselves, each sentence standing ON THE LINE OF THE ROW IT BELONGS TO. +// +// TWO KINDS SHARE IT. A `needs you` question is drawn WHETHER OR NOT its row is +// selected ([homeCell.alwaysSaid]) — the panel exists so a person reads what is +// waiting on them at a glance. Every other row's sentence is drawn only while it +// is the row under the cursor, because a gloss on forty rows at once is a wall. +// +// ALIGNMENT IS WHAT MAKES THE TWO LEGIBLE TOGETHER. Each note starts on its own +// row's line, so which row a sentence is about is a fact about where it is +// rather than something the reader works out, and a note runs down only as far +// as the next note's row so two of them never overlap. // // IT IS BUILT AT PAINT TIME AND NOT AT BUILD TIME, which is the whole reason it // can follow the cursor: the grid's lines are settled when the room or the width @@ -76,52 +86,87 @@ func (a *app) homeGridRows(width, room int, pal palette) []placeRow { // on when the frame was last rebuilt. // // IT FOLLOWS THE POINTER TOO, through the same [homeView.previewLine] the card -// beside the search has always used — hovering a row reads that row here without -// moving the cursor, and a pointer that leaves the column gives the description -// back to the cursor's row. +// beside the search has always used. // // NOTHING IN IT IS A STOP. The column holds no row a cursor may stand on, which // is what makes `→` step over it to the rail ([homeView.gridCrossTarget]) rather // than parking the cursor on a sentence about the row it just left. -func (a *app) homeDescLines(width, room int, pal palette, top int) []homeCellLine { - line, ok := a.home.previewLine() - if !ok || line.cell == nil || room <= 0 { +func (a *app) homeDescLines(width, room int, pal palette, field []homeCellLine) []homeCellLine { + h := &a.home + if room <= 0 || width <= homeGridLead { return nil } - said := strings.TrimSpace(line.cell.sub) - // AND NOTHING AT ALL FOR A ROW THAT KEPT ITS SENTENCE. The column would be - // saying a second time what the row under the cursor is already saying, and - // the same words twice on one frame is the reader wondering which is which. - if said == "" || line.cell.keepsSub() { + room = min(room, len(field)) + preview := h.previewAt() + type note struct { + y int + words []string + } + var notes []note + for y := 0; y < room; y++ { + at := field[y].at + if at == homeNoLine || at < 0 || at >= len(h.lines) { + continue + } + line := h.lines[at] + if line.cell == nil { + continue + } + said := strings.TrimSpace(line.cell.sub) + selected := at == preview + if said == "" || (!line.cell.alwaysSaid() && !selected) { + continue + } + notes = append(notes, note{y: y, words: a.homeDescNote(line, at, said, width, selected, pal)}) + } + if len(notes) == 0 { return nil } - // IT STARTS ON THE ROW IT IS ABOUT. A sentence at the top of a column while - // the row it describes is eight lines down is a sentence a person has to - // work out the owner of; on the row's own line the two read as one thing, - // which is what the second line under the row did for free. - out := make([]homeCellLine, 0, room) - for y := 0; y < top && y < room; y++ { - out = append(out, homeCellLine{at: homeNoLine, head: -1}) - } - // The lead is the rows' own, so the sentence starts in the column every - // title on the screen starts in. - for _, words := range wrap(said, max(1, width-homeGridLead)) { - if len(out) >= room { - break + out := make([]homeCellLine, room) + for i := range out { + out[i] = homeCellLine{at: homeNoLine, head: -1} + } + for i, n := range notes { + // A NOTE RUNS DOWN ONLY AS FAR AS THE NEXT ONE'S ROW. The rest of a + // sentence that does not fit is dropped rather than drawn over somebody + // else's row: the column's whole promise is that a line belongs to the + // row beside it. + stop := room + if i+1 < len(notes) { + stop = notes[i+1].y } - out = append(out, homeCellLine{at: homeNoLine, head: -1, text: homeCellLeadBlank + pal.dim(words)}) - } - // AND THE ANSWERS UNDER IT, on a line of their own — they were at the right - // of the row's second line, and that line is here now. - if answers := strings.TrimSpace(a.homeRowAnswers(line, a.home.cursor)); answers != "" && len(out) < room { - out = append(out, homeCellLine{at: homeNoLine, head: -1}) - if len(out) < room { - out = append(out, homeCellLine{at: homeNoLine, head: -1, text: homeCellLeadBlank + paintHint(answers, pal, pal.dim)}) + for j, words := range n.words { + if n.y+j >= stop { + break + } + out[n.y+j] = homeCellLine{at: homeNoLine, head: -1, text: words} } } return out } +// homeDescNote is one row's note as the lines it takes. +// +// THE SELECTED ROW'S IS WRAPPED AND A PERMANENT ONE IS NOT. The row a person is +// on is the one they are reading, and it gets the room; a `needs you` question +// standing over other rows keeps the one line it had under its row, cut the way +// the row cut it, with its key at the right where the row put it. +func (a *app) homeDescNote(line homeLine, at int, said string, width int, selected bool, pal palette) []string { + room := max(1, width-homeGridLead) + answers := strings.TrimSpace(a.homeRowAnswers(line, at)) + if !selected { + return []string{homeCellLeadBlank + switcherSides(room, said, answers, pal.dim, pal.muted)} + } + var out []string + for _, words := range wrap(said, room) { + out = append(out, homeCellLeadBlank+pal.dim(words)) + } + if answers != "" { + out = append(out, "", homeCellLeadBlank+paintHint(answers, pal, pal.dim)) + } + return out +} + // homeGridZip is one body row: each column's row at its x, and the mark that // says which line of the list each column drew there. func homeGridZip(columns [][]homeCellLine, y int, xs []int) placeRow { @@ -343,7 +388,7 @@ func (a *app) homeCellRow(line homeLine, at, width int, pal palette, lit bool) [ rows := []string{homeCellBand(a.homeCellLead(cell, at, pal)+body, width, pal, lit)} // THE DESCRIPTION COLUMN HAS THIS LINE WHERE THERE IS ONE, so the row is one // line and the panel above it is that much shorter ([homeDescCol]). - if cell.sub == "" || (homeDescOn(a.home.grid.cols) && !cell.keepsSub()) || (cell.grows && at != a.home.cursor) { + if cell.sub == "" || homeDescOn(a.home.grid.cols) || (cell.grows && at != a.home.cursor) { return rows } under := switcherSides(max(1, width-homeGridLead), cell.sub, a.homeRowAnswers(line, at), pal.dim, pal.muted) @@ -515,18 +560,3 @@ func homeCellWidth(title string, pad int, note, tag, right string) int { } return n } - -// homeDescTop is the body row the described line was drawn on, so the -// description can start there. It is the FIELD's own column that is searched, -// because that is the only one whose rows have descriptions. -func homeDescTop(columns [][]homeCellLine, at int) int { - if at == homeNoLine || len(columns) == 0 { - return 0 - } - for y, line := range columns[0] { - if line.at == at { - return y - } - } - return 0 -} diff --git a/internal/tui3/homegrid.go b/internal/tui3/homegrid.go index 841242bdc2..27d0bba589 100644 --- a/internal/tui3/homegrid.go +++ b/internal/tui3/homegrid.go @@ -321,6 +321,12 @@ type homeGridInput struct { spend homeSpendReading seen time.Time now time.Time + // desc is that this frame HAS a description column ([homeDescOn]). A row + // whose sentence only exists to be drawn there does not carry one on a frame + // with nowhere to draw it — a sub reserves a line in its panel's height + // ([homeGridPanel.growsARow]), and paying that on a narrow terminal for a + // column that is not on the screen costs a row a person could have read. + desc bool } // gridInput gathers it. EVERY FIELD IS A CACHE HOME ALREADY HOLDS, and the one @@ -332,7 +338,7 @@ func (h *homeView) gridInput() homeGridInput { reading := readSwitcher(world, h.items, h.fired, here, h.gone, h.seen, h.world.Read, h.ledger) 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, + desc: homeDescOn(h.cols), 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} } @@ -492,22 +498,22 @@ func (l homeLine) height() int { return 1 } -// keepsSub reports a row that draws its own second line even where the -// description column exists ([homeDescOn]). +// alwaysSaid reports a row whose sentence the description column draws WHETHER +// OR NOT the row is selected. // -// `needs you` IS THE ONE EXCEPTION, and the owner made it one on 2026-09-15. -// Everywhere else the second line is a gloss on the row — what a piece of work -// is doing, the first sentence of a report — and a gloss is exactly what a -// column about the selected row is for. On `needs you` the second line IS the -// row: the question is the thing that stopped, and the panel's whole purpose is -// that a person reads what is waiting on them WITHOUT having to walk the cursor -// onto it. A question you must select to read is a question you can miss. +// `needs you` IS THE ONE PANEL THAT GETS THIS, and the owner made it so on +// 2026-09-15. Everywhere else the second line is a gloss on the row — what a +// piece of work is doing, the first sentence of a report — and a gloss is worth +// a column only for the row a person is actually on. On `needs you` the second +// line IS the row: the question is the thing that stopped, and the panel's whole +// purpose is that a person reads what is waiting on them WITHOUT walking the +// cursor onto it. A question you must select to read is a question you can miss. // // It is the permanent one only. A `to check` landing in the same panel grows its -// sentence under the cursor and has never been readable at a glance, so it moves -// to the column like every other row's. -func (c *homeCell) keepsSub() bool { - return c != nil && c.panel == panelNeeds && !c.grows +// sentence under the cursor and has never been readable at a glance, so it is +// drawn only while it is the selected row, like every other row's. +func (c *homeCell) alwaysSaid() bool { + return c != nil && c.panel == panelNeeds && !c.grows && strings.TrimSpace(c.sub) != "" } // ── the layout ───────────────────────────────────────────────────────────── @@ -576,7 +582,7 @@ func (p homeGridPanel) height() int { // A ROW IS ONE LINE WHERE THE DESCRIPTION COLUMN HAS ITS SECOND, and the // reservation below goes with it — unless it is a row that keeps its own // ([homeCell.keepsSub]). - if p.desc && !line.cell.keepsSub() { + if p.desc { n++ continue } @@ -1477,6 +1483,13 @@ func (a *app) refreshGridReadings(now time.Time) tea.Cmd { asked = append(asked, a.askHomeLeftOff(line.row.Transcript)) } } + // AND THE TAIL OF THE ROW BEING READ, for the description column. One peek + // per conversation a person actually stops on, cached for the life of the + // window like every other — a screen that read all forty journals to fill a + // column about one row would be paying for thirty-nine nobody looked at. + if line, ok := a.home.previewLine(); ok && line.kind == homeSession { + asked = append(asked, a.askHomeLeftOff(line.row.Transcript)) + } return tea.Batch(asked...) } diff --git a/internal/tui3/homegrid_test.go b/internal/tui3/homegrid_test.go index fbecd4f63e..513f62e5c7 100644 --- a/internal/tui3/homegrid_test.go +++ b/internal/tui3/homegrid_test.go @@ -169,7 +169,7 @@ func TestTheDescriptionColumnCarriesTheSelectedRowsOwnSentence(t *testing.T) { a := newSwitchLab(t).open(180, 45) homeText(a) homeLineOf(t, a, func(l homeLine) bool { - return l.cell != nil && strings.TrimSpace(l.cell.sub) != "" && !l.cell.keepsSub() + return l.cell != nil && strings.TrimSpace(l.cell.sub) != "" && !l.cell.alwaysSaid() }) line, _ := a.home.focusedLine() said := strings.TrimSpace(line.cell.sub) @@ -197,11 +197,12 @@ func TestTheDescriptionColumnCarriesTheSelectedRowsOwnSentence(t *testing.T) { } } -// `needs you` KEEPS ITS QUESTION UNDER ITS ROW even where the description column -// exists, and the column does not repeat it. Everywhere else the second line is -// a gloss; here it is the row — a question you have to select to read is a -// question you can miss (owner, 2026-09-15). -func TestTheNeedsYouQuestionStaysUnderItsRowAndIsNotRepeatedInTheColumn(t *testing.T) { +// `needs you` DRAWS ITS QUESTION IN THE DESCRIPTION COLUMN WHETHER OR NOT ITS +// ROW IS SELECTED, on its own row's line, and once. Everywhere else the second +// line is a gloss worth a column only for the row being read; here it is the row +// — a question you have to select to read is a question you can miss (owner, +// 2026-09-15). +func TestTheNeedsYouQuestionIsAlwaysInTheColumnOnItsOwnRowsLine(t *testing.T) { lab := newAnswerLab(t, consentQuestion(7, "needs your ok to run bash"), time.Now()) a := lab.a a.width, a.height = 180, 45 @@ -211,17 +212,30 @@ func TestTheNeedsYouQuestionStaysUnderItsRowAndIsNotRepeatedInTheColumn(t *testi }) line, _ := a.home.focusedLine() said := strings.TrimSpace(line.cell.sub) - if !line.cell.keepsSub() { - t.Fatalf("a needs you question does not keep its own line") - } + if !line.cell.alwaysSaid() { + t.Fatalf("a needs you question is not drawn whether or not its row is selected") + } + // IT IS IN THE COLUMN WHILE THE CURSOR IS SOMEWHERE ELSE ENTIRELY, which is + // the whole point: what is waiting on you is readable without walking onto + // it. + a.home.cursor = a.home.placesTop() + homeLineOf(t, a, func(l homeLine) bool { return l.cell != nil && l.cell.panel == panelRecent }) frame := homeText(a) - lines := strings.Split(frame, "\n") + row, at := homeRowOf(frame, firstWordsOf(said)) + if row < 0 { + t.Fatalf("the question left the frame when the cursor moved off its row:\n%s", frame) + } + _, rail := homeRowOf(frame, "projects · ") + if at <= homeGridMargin || at >= rail { + t.Fatalf("the question is at cell %d, want the description column between %d and %d:\n%s", at, homeGridMargin, rail, frame) + } + // AND ON ITS OWN ROW'S LINE, so which row it belongs to is where it is. title, _ := homeRowOf(frame, line.cell.title) - if title < 0 || title+1 >= len(lines) || !strings.Contains(lines[title+1], firstWordsOf(said)) { - t.Fatalf("the question is not on the line under its row:\n%s", frame) + if title != row { + t.Fatalf("the question is on row %d and its row is on %d:\n%s", row, title, frame) } - // AND ONCE ONLY. The column saying it a second time is the same words twice - // on one frame. + // AND ONCE ONLY. The same words twice on one frame is the reader wondering + // which is which. if n := strings.Count(frame, firstWordsOf(said)); n != 1 { t.Fatalf("the question is on the frame %d times, want once:\n%s", n, frame) } diff --git a/internal/tui3/homepanel_needs.go b/internal/tui3/homepanel_needs.go index 91019cb2a7..a93408609d 100644 --- a/internal/tui3/homepanel_needs.go +++ b/internal/tui3/homepanel_needs.go @@ -140,18 +140,32 @@ func needsAsked(in *homeGridInput) []needsItem { needsLandingsSpeakFor(row.session, needsCallTitlesOn(in, row.session.ID))) { continue } - cell := &homeCell{panel: panelNeeds, mark: cellMarkNeeds, title: row.title, subRight: needsOpenWord} + cell := &homeCell{panel: panelNeeds, mark: cellMarkNeeds, title: row.title} homeLiveMargin(cell, row, sinceAt(row.at, in.now)) item := needsItem{asked: row.at} switch row.kind { case switcherConversation: head, whole := needsSentence(row.session) cell.sub = head + // A CONVERSATION ALWAYS HAS A DOOR: it is the conversation the + // question was asked in, and enter goes to it. + cell.subRight = needsOpenWord if _, ok := answerable(row.session, in.now); ok && whole { cell.answers = answersWord(row.session.Presence.Question) } case switcherStanding: cell.sub = switcherFirstLine(row.item.Item.NeedsPerson) + // AND A WATCH HAS ONE ONLY WHERE IT WAS ASKED FOR IN A CONVERSATION. + // An item made from home's own box keeps its exchange under the item's + // folder rather than as a session ([standing.Origin.Exchange]), and + // [app.homeItemEnter] opens a TRANSCRIPT — so it answers `made from + // home — no conversation to open`, and a row that drew `enter` beside + // that sentence was promising a key it had already decided against. + // It is the legend's law on the row itself ([drawKeysBand]): NAME + // ONLY KEYS THAT WORK. + if strings.TrimSpace(row.item.Item.Origin.Transcript) != "" { + cell.subRight = needsOpenWord + } } item.line = switcherRowLine(row, cell) items = append(items, item) diff --git a/internal/tui3/homepanel_next.go b/internal/tui3/homepanel_next.go index 98b0ed07a8..e0097b8603 100644 --- a/internal/tui3/homepanel_next.go +++ b/internal/tui3/homepanel_next.go @@ -22,7 +22,12 @@ func (nextPanel) rows(in *homeGridInput) homePanelRows { item := view.Item lines = append(lines, homeLine{kind: homeLedger, project: pageStanding.word(), dir: item.ID, view: view, item: item, cell: &homeCell{panel: panelNext, - title: strings.TrimSpace(item.Words), right: standWhenClause(item, in.now)}}) + title: strings.TrimSpace(item.Words), right: standWhenClause(item, in.now), + // WHAT WAKES IT, under the cursor. The row's right-hand clause is + // WHEN the next one is; this is the standing arrangement behind + // it, in the person's own words where they gave any, and it is + // drawn only for the row being read ([homeDescLines]). + grows: in.desc, sub: nextUpSaidOn(in, item)}}) } return homePanelRows{lines: lines, more: len(views) - shown} } @@ -44,3 +49,22 @@ func nextActive(in *homeGridInput) []StandingItemView { } return views } + +// nextUpSaid is the sentence the description column draws for a `next up` row: +// the arrangement in the person's own words, and nothing at all where those +// words are the row's title already — the same sentence twice is the emptiness +// law's cousin, and the row has said it once. +func nextUpSaidOn(in *homeGridInput, item standing.Item) string { + if !in.desc { + return "" + } + return nextUpSaid(item) +} + +func nextUpSaid(item standing.Item) string { + said := strings.TrimSpace(item.When.Words) + if said == "" || strings.EqualFold(said, strings.TrimSpace(item.Words)) { + return "" + } + return said +} diff --git a/internal/tui3/homepanel_recent.go b/internal/tui3/homepanel_recent.go index f9aee23322..60db281c92 100644 --- a/internal/tui3/homepanel_recent.go +++ b/internal/tui3/homepanel_recent.go @@ -77,7 +77,16 @@ func recentOwnCell(row switcherRow, in *homeGridInput) *homeCell { // not this window's own folder, where the tag would be the same word on every // row, and only when the folder has a name ([chatProjectTag]). func recentCell(row switcherRow, in *homeGridInput) *homeCell { + // THE LAST THING THIS PERSON SAID IN IT, under the cursor — which is the one + // fact that tells two conversations with similar names apart, and the same + // reading this window's own row has always drawn ([recentOwnCell]). It is + // empty until the journal's tail has come back ([app.askHomeLeftOff] asks for + // the row being read, off the draw), and a row with nothing to say draws + // nothing rather than a gap. cell := &homeCell{panel: panelRecent, title: row.title, right: switcherMarginWord(row)} + if in.desc { + cell.grows, cell.sub = true, switcherFirstLine(in.last[row.session.Transcript].LastUser) + } cell.hold = cell.right != row.age if row.door && cell.right == homeHeldShort { cell.door = takeoverHeldDoorWord From 137fd6d37f2a8049942fe4e5f043b8276d0e18b9 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:12:57 -0400 Subject: [PATCH 07/23] tui3: a row's keys are its own, and enter on a home-made watch opens it on standing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit THE KEYS BELONG TO THE SELECTED ROW AND NOBODY ELSE. A `needs you` question is in the description column whether or not its row is selected, and it was drawing `enter` beside it the whole time — a key advertised on a row a person is not standing on is a key that would do something else if they pressed it, which is law 7 inverted (a row draws its answers so the key is never a guess). The sentence now stands alone until its row is the one being read, and the key joins it at the right of its own line rather than on a second line below. A PERMANENT NOTE IS ONE LINE, SELECTED OR NOT. It shares the column with rows above and below and may not grow into them; only a note that is there BECAUSE it is selected has the column to itself and wraps. AND `enter` ON A WATCH MADE AT HOME OPENS IT ON STANDING. It refused in a sentence and left the cursor where it was, which is the worst of both — nothing happens AND the screen explains why on the row somebody is trying to leave. Such an item has no conversation behind it (its exchange is kept under the item's folder rather than as a session) but it has a page, and that is the honest answer to "show me this thing". The row and the card both say which door it is: `enter open where it was asked` where there is a conversation, `enter open it on standing` where there is not. THE WAIT ON A `where you were` NOTE IS REMOVED RATHER THAN INDICATED. The owner asked for a spinner while the journal tail is read; law 8 gives the screen's one moving cell to work and nothing else, and TestExactlyOneRowIsGivenTheSpinner pins it. So the peek is asked on the KEY that moved the cursor instead of on the next beat — refreshHomeCard already ran after every key and returned early on the grid, on the reading that the grid has nothing about the row under the cursor, which the description column has now made false. The sentence lands in well under half a second, and there is no wait left to announce. Reading every conversation on the screen up front was the other way and costs a dozen whole-transcript scans, 33ms each, for the one row somebody is looking at. Co-Authored-By: Claude Opus 5 --- internal/tui3/homeband_keys.go | 6 +++-- internal/tui3/homebands_ambientb_test.go | 14 ++++++------ internal/tui3/homecardread.go | 29 ++++++++++++++++++------ internal/tui3/homecell.go | 22 +++++++++++++----- internal/tui3/homegrid.go | 7 ------ internal/tui3/homepanel_needs.go | 23 +++++++++++-------- internal/tui3/homestanding.go | 14 ++++++++++-- internal/tui3/homestanding_test.go | 14 ++++++++---- 8 files changed, 83 insertions(+), 46 deletions(-) diff --git a/internal/tui3/homeband_keys.go b/internal/tui3/homeband_keys.go index 636c253738..5ea88e02cb 100644 --- a/internal/tui3/homeband_keys.go +++ b/internal/tui3/homeband_keys.go @@ -43,9 +43,11 @@ func drawKeysBand(a *app, ctx bandContext) []string { // already decided it does not have — which is this legend's own law, said // three paragraphs up about a conversation whose folder is gone, and the // design's: A CAPABILITY THAT CANNOT WORK IS ABSENT, NOT BROKEN. - if strings.TrimSpace(ctx.subject.item.Item.Origin.Transcript) != "" { - clauses = append([]string{homeItemEnterWord}, clauses...) + open := homeItemEnterWord + if strings.TrimSpace(ctx.subject.item.Item.Origin.Transcript) == "" { + open = homeItemStandingWord } + clauses = append([]string{open}, clauses...) // AND THE RUNG'S CHORD ONLY WHERE THERE IS A DOOR TO MOVE IT THROUGH, // which is the same rule the three above are drawn under — a read-only // window keeps every key that asks nothing of the disk and loses the ones diff --git a/internal/tui3/homebands_ambientb_test.go b/internal/tui3/homebands_ambientb_test.go index e31e7a8ef6..c565d43deb 100644 --- a/internal/tui3/homebands_ambientb_test.go +++ b/internal/tui3/homebands_ambientb_test.go @@ -77,14 +77,14 @@ func TestKeysBandDrawsBothLegendsAndObeysWidth(t *testing.T) { t.Fatalf("session keys = %q", got) } assertNarrowRows(t, "keys", drawKeysBand(a, ambientBandContextAt(a, 30, time.Now())), 30, "→ more") - // AN ITEM NAMES `enter` ONLY WHERE THERE IS A CONVERSATION BEHIND IT. The - // legend's own law is that it names keys that work, and an item made at home - // has no origin to open — [app.homeItemEnter] refuses it in words, so a - // legend that advertised the key would be inviting a keystroke the row has - // already decided against. + // AN ITEM NAMES THE DOOR IT ACTUALLY HAS. One asked for in a conversation + // opens that conversation; one made at home has none — its exchange is kept + // under the item's folder rather than as a session — and `enter` opens it on + // standing instead ([app.homeItemEnter]). Two doors, two words, and neither + // row advertises the other's. ctx.subject.kind = bandKindItem - if got := plain(drawKeysBand(a, ctx)[0]); strings.Contains(got, "enter") { - t.Fatalf("an item with no origin names enter: %q", got) + if got := plain(drawKeysBand(a, ctx)[0]); !strings.HasPrefix(got, homeItemStandingWord) { + t.Fatalf("an item with no conversation behind it says %q, want %q", got, homeItemStandingWord) } ctx.subject.item.Item.Origin.Transcript = "/work/.codeaf/v3/projects/-work/s1/transcript.jsonl" if got := plain(drawKeysBand(a, ctx)[0]); !strings.HasPrefix(got, "enter open where") { diff --git a/internal/tui3/homecardread.go b/internal/tui3/homecardread.go index ff9a66d970..af62a5c971 100644 --- a/internal/tui3/homecardread.go +++ b/internal/tui3/homecardread.go @@ -67,17 +67,32 @@ func (a *app) refreshHomeCard(now time.Time) tea.Cmd { if !a.at(pageHome) { return nil } - // THE GRID HAS NO CARD, and what it reads is about the rows it draws rather - // than about the one under the cursor — so a key or a pointer asks for - // nothing, and the beat and the open ask for the grid's readings - // (homegrid.go's [app.refreshGridReadings]). - if a.home.gridOn() { - return nil - } line, ok := a.home.previewLine() if !ok { return nil } + // THE GRID HAS NO CARD, but it has the DESCRIPTION COLUMN, which is about the + // row under the cursor and nothing else ([homeDescLines]) — so the one + // reading that column needs is asked for here, on the key that moved the + // cursor, rather than waiting for the next beat. It is the same peek, behind + // the same cache: a conversation is read once for the life of the window and + // a second ask costs a map lookup ([app.askHomeLeftOff]). + // + // EVERYTHING ELSE THE GRID DRAWS IS ABOUT THE ROWS RATHER THAN THE CURSOR and + // is still asked for on the beat and at the open (homegrid.go's + // [app.refreshGridReadings]). Reading every conversation on the screen up + // front to fill this column would be a dozen whole-transcript scans — 33ms + // each — for the one row somebody is looking at. + if a.home.gridOn() { + // AND ONLY ON A FRAME THAT HAS THE COLUMN. Narrower than three columns + // there is nowhere to draw the sentence, so asking for it would be a + // whole-transcript scan for nothing — and the rebuild its arrival + // triggers would drop the pick off the row a person just walked onto. + if !homeDescOn(a.home.cols) || line.kind != homeSession { + return nil + } + return a.askHomeLeftOff(line.row.Transcript) + } var asked []tea.Cmd switch { case line.kind == homeSession: diff --git a/internal/tui3/homecell.go b/internal/tui3/homecell.go index 268ccccc26..5ded386ec5 100644 --- a/internal/tui3/homecell.go +++ b/internal/tui3/homecell.go @@ -147,14 +147,24 @@ func (a *app) homeDescLines(width, room int, pal palette, field []homeCellLine) // homeDescNote is one row's note as the lines it takes. // -// THE SELECTED ROW'S IS WRAPPED AND A PERMANENT ONE IS NOT. The row a person is -// on is the one they are reading, and it gets the room; a `needs you` question -// standing over other rows keeps the one line it had under its row, cut the way -// the row cut it, with its key at the right where the row put it. +// THE KEYS ARE THE SELECTED ROW'S AND NOBODY ELSE'S. A permanent note — a +// `needs you` question standing over rows the cursor is not on — is the sentence +// alone: `enter` beside a row a person is not standing on is a key that would do +// something else if they pressed it, and the surface may never advertise one of +// those (law 7 draws a row's answers so the key is never a guess). The moment the +// row IS the one being read, its keys join it at the right of its own line, in +// the one place they have ever been. +// +// AND A PERMANENT NOTE IS ONE LINE, SELECTED OR NOT. It shares the column with +// rows above and below it and may not grow into them; only a note that is there +// BECAUSE it is selected has the column to itself and wraps. func (a *app) homeDescNote(line homeLine, at int, said string, width int, selected bool, pal palette) []string { room := max(1, width-homeGridLead) - answers := strings.TrimSpace(a.homeRowAnswers(line, at)) - if !selected { + answers := "" + if selected { + answers = strings.TrimSpace(a.homeRowAnswers(line, at)) + } + if line.cell.alwaysSaid() { return []string{homeCellLeadBlank + switcherSides(room, said, answers, pal.dim, pal.muted)} } var out []string diff --git a/internal/tui3/homegrid.go b/internal/tui3/homegrid.go index 27d0bba589..b8c7f33ca8 100644 --- a/internal/tui3/homegrid.go +++ b/internal/tui3/homegrid.go @@ -1483,13 +1483,6 @@ func (a *app) refreshGridReadings(now time.Time) tea.Cmd { asked = append(asked, a.askHomeLeftOff(line.row.Transcript)) } } - // AND THE TAIL OF THE ROW BEING READ, for the description column. One peek - // per conversation a person actually stops on, cached for the life of the - // window like every other — a screen that read all forty journals to fill a - // column about one row would be paying for thirty-nine nobody looked at. - if line, ok := a.home.previewLine(); ok && line.kind == homeSession { - asked = append(asked, a.askHomeLeftOff(line.row.Transcript)) - } return tea.Batch(asked...) } diff --git a/internal/tui3/homepanel_needs.go b/internal/tui3/homepanel_needs.go index a93408609d..f5973407ba 100644 --- a/internal/tui3/homepanel_needs.go +++ b/internal/tui3/homepanel_needs.go @@ -53,6 +53,10 @@ const ( // which is the emptiness law rather than an instruction repeated on every // row. enter still opens the record ([app.homeLandOnTask]). needsOpenWord = "enter" + // needsStandingWord is the same key on a watch with no conversation behind + // it: `enter` opens the item on the standing place, which is where an item + // made at home lives. + needsStandingWord = "enter on standing" // 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 // every answer is still one enter away. @@ -155,16 +159,15 @@ func needsAsked(in *homeGridInput) []needsItem { } case switcherStanding: cell.sub = switcherFirstLine(row.item.Item.NeedsPerson) - // AND A WATCH HAS ONE ONLY WHERE IT WAS ASKED FOR IN A CONVERSATION. - // An item made from home's own box keeps its exchange under the item's - // folder rather than as a session ([standing.Origin.Exchange]), and - // [app.homeItemEnter] opens a TRANSCRIPT — so it answers `made from - // home — no conversation to open`, and a row that drew `enter` beside - // that sentence was promising a key it had already decided against. - // It is the legend's law on the row itself ([drawKeysBand]): NAME - // ONLY KEYS THAT WORK. - if strings.TrimSpace(row.item.Item.Origin.Transcript) != "" { - cell.subRight = needsOpenWord + // AND THE KEY SAYS WHICH DOOR IT IS. A watch asked for in a + // conversation opens that conversation; one made from home's own box + // has none — its exchange is kept under the item's folder rather than + // as a session ([standing.Origin.Exchange]) — and `enter` opens the + // item where it does live, on standing ([app.homeItemEnter]). Two + // doors, two words, and neither row advertises the other's. + cell.subRight = needsOpenWord + if strings.TrimSpace(row.item.Item.Origin.Transcript) == "" { + cell.subRight = needsStandingWord } } item.line = switcherRowLine(row, cell) diff --git a/internal/tui3/homestanding.go b/internal/tui3/homestanding.go index c30de98c82..7a8f36289e 100644 --- a/internal/tui3/homestanding.go +++ b/internal/tui3/homestanding.go @@ -79,6 +79,9 @@ const ( // line from the verbs the row under the cursor actually has and needs this // half without the other two ([standingPlace.hint]). homeItemEnterWord = "enter open where it was asked" + // homeItemStandingWord is the same key on an item made at home, which has no + // conversation behind it to open ([app.homeItemEnter]). + homeItemStandingWord = "enter open it on standing" // homeItemNoDoor is what enter says on an item that was made at home and // never became a conversation ([standing.Origin.Exchange]). It is a fact and // not a refusal: there genuinely is no transcript to open, and saying so is @@ -736,8 +739,15 @@ func (a *app) homeItemEnter(line homeLine) tea.Cmd { h := &a.home transcript := strings.TrimSpace(line.item.Origin.Transcript) if transcript == "" { - h.say(homeItemNoDoor, "") - return nil + // AN ITEM MADE AT HOME HAS NO CONVERSATION TO OPEN, and the standing + // place is where it does live — so `enter` goes there rather than + // refusing. It was a dead key with a sentence beside it, which is the + // worst of both: nothing happens AND the screen explains why on the row + // a person is trying to leave. The item's own page is the honest answer + // to "show me this thing", and it is the same door a `next up` row takes + // ([app.homeLedgerEnter]). + a.closeHome() + return a.showPage(pageStanding) } // THE CAPABILITY IS ASKED THROUGH THE ONE PREDICATE, never off a single seam: // a door may answer the whole conversation ([Options.Open]) rather than the diff --git a/internal/tui3/homestanding_test.go b/internal/tui3/homestanding_test.go index 9e4136c002..3ffcb576b8 100644 --- a/internal/tui3/homestanding_test.go +++ b/internal/tui3/homestanding_test.go @@ -561,11 +561,15 @@ func TestEnterOnAnItemOpensWhereItWasAsked(t *testing.T) { a.openHome() a.home.pointItemForTest("one") drive(t, a, key("enter")) - if a.home.msg != homeItemNoDoor { - t.Fatalf("an item made at home said %q, want %q", a.home.msg, homeItemNoDoor) - } - if !a.at(pageHome) { - t.Fatal("home closed on a door that goes nowhere") + // AN ITEM MADE AT HOME OPENS ON STANDING, which is where it lives. It used to + // refuse in a sentence and leave the cursor where it was, which is the worst + // of both — nothing happens AND the screen explains why on the row a person + // is trying to leave (owner, 2026-09-15). + if a.at(pageHome) { + t.Fatalf("enter on an item made at home stayed on home, saying %q", a.home.msg) + } + if !a.at(pageStanding) { + t.Fatal("enter on an item made at home did not open the standing place") } } From 63bc1a203ca1721349b540ac8f8721280cfb2db3 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:36:56 -0400 Subject: [PATCH 08/23] tui3: a question home raises is drawn in the description column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `enter` on a conversation another window holds asks before taking it — codeaf does not move somebody's conversation out from under them silently, and the answers carry what it costs ("its reply stops there; its tasks come here"). The question is raised as the block's own card, and on the grid that card had NOWHERE TO DRAW: it belongs to the detail column beside the typed search, which is not up at rest. So all a person got was the one-line foot version, appended to the resting sentence — where it read as a run-on and was missed. Pressing enter looked like it changed the bottom of the screen for no reason. The description column is where it goes now, beside the row whose enter raised it, drawn by [app.questionPanelRows] like every other question on this surface — the frame, the mark, the head, the answers in a column with what each one costs, and the safe one marked. It takes the column WHOLE: notes stacked around a decision a person has to answer are the screen talking over it. Where the card is taller than the room under its row it is pulled up rather than cut, because a decision with its last answer off the bottom of the screen is worse than one drawn a few lines above its row. And the foot keeps its own sentence. A question said in both places is one decision drawn twice, which is the defect the block exists to end — [sayHomeAsk] already refused to double up beside a card and simply did not know the grid had one. Co-Authored-By: Claude Opus 5 --- internal/tui3/homecell.go | 59 ++++++++++++++++++++++++++++++++++ internal/tui3/homeconfirm.go | 10 ++++-- internal/tui3/homegrid_test.go | 39 ++++++++++++++++++++++ 3 files changed, 106 insertions(+), 2 deletions(-) diff --git a/internal/tui3/homecell.go b/internal/tui3/homecell.go index 5ded386ec5..cd2fb23b74 100644 --- a/internal/tui3/homecell.go +++ b/internal/tui3/homecell.go @@ -97,6 +97,13 @@ func (a *app) homeDescLines(width, room int, pal palette, field []homeCellLine) return nil } room = min(room, len(field)) + // A QUESTION HOME HAS RAISED TAKES THE WHOLE COLUMN, beside the row it is + // about. One decision is drawn once and nothing is drawn beside it: the + // column is otherwise a set of notes about rows, and notes stacked around a + // question a person has to answer are the screen talking over it. + if rows := a.homeAskNote(field, width, room); rows != nil { + return rows + } preview := h.previewAt() type note struct { y int @@ -570,3 +577,55 @@ func homeCellWidth(title string, pad int, note, tag, right string) int { } return n } + +// homeAskNote is the question home is holding, drawn in the description column +// on the line of the row whose `enter` raised it, and nil where there is no +// question or its row is not in the field. +// +// IT IS THE QUESTION BLOCK'S OWN CARD ([app.homeAskRows]) and not a second +// drawing of the same facts, so a person who has learnt one question on this +// surface has learnt this one. Until this column existed the grid had nowhere to +// put it: the card belongs to the detail column beside the typed search, which +// is not up at rest, so a raised question left only its one-line foot version — +// appended to the resting sentence, where it read as a run-on and was missed +// (owner, 2026-09-15). +// +// IT IS PULLED UP RATHER THAN CUT where the card is taller than the room under +// its row. A decision with its last answer off the bottom of the screen is worse +// than one drawn a few lines above the row it belongs to. +func (a *app) homeAskNote(field []homeCellLine, width, room int) []homeCellLine { + h := &a.home + if _, ok := a.homeAsking(); !ok || strings.TrimSpace(h.armed) == "" { + return nil + } + at := homeNoLine + for y := 0; y < room; y++ { + line := field[y].at + if line == homeNoLine || line < 0 || line >= len(h.lines) { + continue + } + if h.lines[line].kind == homeSession && strings.TrimSpace(h.lines[line].row.Transcript) == h.armed { + at = y + break + } + } + if at == homeNoLine { + return nil + } + said := a.homeAskRows(max(1, width-homeGridLead)) + if len(said) == 0 { + return nil + } + top := min(at, max(0, room-len(said))) + out := make([]homeCellLine, room) + for i := range out { + out[i] = homeCellLine{at: homeNoLine, head: -1} + } + for i, words := range said { + if top+i >= room { + break + } + out[top+i] = homeCellLine{at: homeNoLine, head: -1, text: homeCellLeadBlank + words} + } + return out +} diff --git a/internal/tui3/homeconfirm.go b/internal/tui3/homeconfirm.go index 997670d1f4..79a54cec68 100644 --- a/internal/tui3/homeconfirm.go +++ b/internal/tui3/homeconfirm.go @@ -154,8 +154,14 @@ func (a *app) homeAskFoot() string { // sayHomeAsk puts that sentence on the foot, and does nothing on a frame that // has a card to draw the question properly on. func (a *app) sayHomeAsk() { - // THE GRID HAS NO CARD AT ANY WIDTH (homegrid.go), so on it the foot is - // where the question is said, as it is on every narrow frame. + // THE GRID DRAWS THE QUESTION IN ITS DESCRIPTION COLUMN where it has one + // ([homeAskNote]), and a foot repeating it would be one decision drawn twice + // on one screen — the defect the whole block exists to end. Narrower than + // that the grid has nowhere to put a card, so the foot is where the question + // is said, as it is on every narrow frame. + if a.home.gridOn() && homeDescOn(a.home.cols) { + return + } if a.homeTierNow() != homeTierList && !a.home.gridOn() { return } diff --git a/internal/tui3/homegrid_test.go b/internal/tui3/homegrid_test.go index 513f62e5c7..d282a8cbd9 100644 --- a/internal/tui3/homegrid_test.go +++ b/internal/tui3/homegrid_test.go @@ -241,6 +241,45 @@ func TestTheNeedsYouQuestionIsAlwaysInTheColumnOnItsOwnRowsLine(t *testing.T) { } } +// A QUESTION HOME RAISES IS DRAWN IN THE DESCRIPTION COLUMN, beside the row +// whose `enter` raised it — and the foot does not say it a second time. +// +// Until the column existed the grid had nowhere to put the card: it belongs to +// the detail column beside the typed search, which is not up at rest, so a +// raised question left only its one-line foot version appended to the resting +// sentence. A person pressing enter saw the bottom of the screen change and read +// it as noise (owner, 2026-09-15). +func TestARaisedQuestionIsDrawnInTheDescriptionColumnAndNotOnTheFoot(t *testing.T) { + lab := newAnswerLab(t, consentQuestion(7, "needs your ok to run bash"), time.Now()) + a := lab.a + a.width, a.height = 180, 40 + homeText(a) + homeLineOf(t, a, func(l homeLine) bool { + return l.cell != nil && l.cell.panel == panelNeeds && l.kind == homeSession + }) + a.placeKeyPress(key("enter")) + ask, ok := a.homeAsking() + if !ok { + t.Fatal("enter on a row another window holds raised no question") + } + frame := homeText(a) + head := strings.TrimSpace(ask.question.Head) + row, at := homeRowOf(frame, head) + if row < 0 { + t.Fatalf("the question %q is not on the frame:\n%s", head, frame) + } + _, rail := homeRowOf(frame, "projects · ") + if at <= homeGridMargin || at >= rail { + t.Fatalf("the question is at cell %d, want the description column between %d and %d:\n%s", at, homeGridMargin, rail, frame) + } + // AND THE FOOT KEEPS ITS OWN SENTENCE. One decision drawn twice on one screen + // is the defect the question block exists to end. + lines := strings.Split(frame, "\n") + if foot := lines[len(lines)-1]; strings.Contains(foot, head) { + t.Fatalf("the foot repeats the question: %q", foot) + } +} + // firstWordsOf is enough of a sentence to find it on a frame that may have // wrapped the rest of it. func firstWordsOf(said string) string { From f805de36b29436c83fec83f22b8eef47df881842 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:58:36 -0400 Subject: [PATCH 09/23] tui3: a watch asked for in another project opens from here MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `enter` on a `needs you` watch answered nothing but a clause at the end of the foot — `elsewhere · /private/tmp/codeaf-demo/codeaf` — on every window that was not launched inside the folder that made the watch. The owner read it as a dead key, twice, and it was: the sentence it wrote is the last clause of a line whose first four clauses are key hints, which is not where a person looks for the answer to a keystroke. THE GUARD WAS THE REPEALED RULE. [app.homeItemEnter] refused any origin outside the window's own project bucket, on the reading that a conversation in another project is out of reach. That reading is gone, and the word the refusal borrowed says so itself — [homeElsewhereWord]: "IT NO LONGER MARKS A ROW THIS WINDOW CANNOT OPEN, because there is no such row: enter opens any project on this screen". A `where you were` row in another project has opened from here for as long as the grid has existed ([app.homeOpenLine] asks the capability and the lock, never the bucket); this path was the last one holding the old rule. So the bucket check goes and the capability check stays, in the sentence a conversation row already says when this window has no door at all. Co-Authored-By: Claude Opus 5 --- internal/tui3/homestanding.go | 19 +++++++++++---- internal/tui3/homestanding_test.go | 37 ++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/internal/tui3/homestanding.go b/internal/tui3/homestanding.go index 7a8f36289e..3e17f7d9f9 100644 --- a/internal/tui3/homestanding.go +++ b/internal/tui3/homestanding.go @@ -754,12 +754,23 @@ func (a *app) homeItemEnter(line homeLine) tea.Cmd { // older agent-alone [Options.Resume], and a row that read only the second // would refuse to open a conversation this window can plainly open // (app.go's [app.canOpen]). - if !a.canOpen() || filepath.Clean(homeBucketOf(transcript)) != h.bucket { - // The same limit a conversation in another project meets, said in the - // same words and naming the same place to go (home.go's header). - h.say(homeElsewhereWord+" · "+standWhere(line), strings.TrimSpace(line.item.Workspace)) + if !a.canOpen() { + // The same sentence a conversation row says when this window has no door + // to open one with ([app.homeOpenLine]). + h.say(resumeUnavailableWord, "") return nil } + // AND THE PROJECT THE CONVERSATION IS IN IS NOT A LIMIT. This used to refuse + // any origin outside the window's own bucket — `elsewhere · ` on the + // foot — on the reading that a conversation in another project is out of + // reach. That reading was repealed for conversations and the word itself + // says so ([homeElsewhereWord]: "IT NO LONGER MARKS A ROW THIS WINDOW CANNOT + // OPEN, because there is no such row: enter opens any project on this + // screen"), and a `where you were` row in another project has opened from + // here ever since. Only this path kept the old guard, so a watch asked for in + // one project answered nothing at all from a window standing in another — + // which is every window that did not happen to be launched inside that + // folder (owner, 2026-09-15). if transcript == a.file { a.closeHome() return nil diff --git a/internal/tui3/homestanding_test.go b/internal/tui3/homestanding_test.go index 3ffcb576b8..300e5de96a 100644 --- a/internal/tui3/homestanding_test.go +++ b/internal/tui3/homestanding_test.go @@ -573,6 +573,43 @@ func TestEnterOnAnItemOpensWhereItWasAsked(t *testing.T) { } } +// A WATCH ASKED FOR IN ANOTHER PROJECT STILL OPENS FROM HERE. It used to refuse +// any origin outside the window's own bucket — `elsewhere · ` on the foot +// — which meant a watch answered nothing at all from every window not launched +// inside the folder that made it. A `where you were` row in another project has +// opened from here for as long as the grid has existed; this path was the last +// one holding the repealed rule (owner, 2026-09-15). +func TestAWatchAskedForInAnotherProjectStillOpensFromHere(t *testing.T) { + lab := newHomeLab(t) + now := time.Now() + mine := lab.session("alpha", "s1", "Pricing Research", "/w/alpha", now.Add(-time.Hour)) + asked := lab.session("beta", "s2", "Standing Up the Watches", "/w/beta", now.Add(-2*time.Hour)) + + band := &standBand{} + watch := bandItem("watch", "tell me when CI goes red", "/w/beta", standing.WhenProbe, "when CI goes red") + watch.NeedsPerson = "may I re-run it?" + watch.LastChecked = now.Add(-time.Minute) + watch.Created = now.Add(-time.Hour) + watch.Origin = standing.Origin{SessionID: "s2", Transcript: asked} + band.items = []standing.Item{watch} + + a := lab.app(mine) + a.workspace = "/w/alpha" + band.wire(a) + a.width, a.height = 180, 40 + openHomeOn(a, mine) + homeText(a) + + homeLineOf(t, a, func(l homeLine) bool { return l.kind == homeItem }) + drive(t, a, key("enter")) + if a.at(pageHome) { + t.Fatalf("enter on a watch from another project stayed on home, saying %q", a.home.msg) + } + if a.file != asked { + t.Fatalf("enter opened %q, want the conversation that asked for it %q", a.file, asked) + } +} + // THE ◆ IS DERIVED AND NEVER ASSERTED: it means the thing went off after the // last time this person spoke in the conversation that asked for it, and an item // with no conversation to compare against does not wear it at all. From 93bfba05682749a6e01beb694178a525f6e3342f Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Tue, 15 Sep 2026 16:00:31 -0400 Subject: [PATCH 10/23] tui3: a question raised by the launch draws in the column too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The card stood beside the row whose enter raised it and drew nothing at all where there was no such row — which is every question the LAUNCH raises, a window meeting a lock on the conversation it was opening. The foot stops saying a question once this column can draw one, so that combination put a decision on the screen with nothing on the screen about it. A question with no row of its own stands at the top of the column. Co-Authored-By: Claude Opus 5 --- internal/tui3/homecell.go | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/internal/tui3/homecell.go b/internal/tui3/homecell.go index cd2fb23b74..d57dca8b7d 100644 --- a/internal/tui3/homecell.go +++ b/internal/tui3/homecell.go @@ -595,23 +595,29 @@ func homeCellWidth(title string, pad int, note, tag, right string) int { // than one drawn a few lines above the row it belongs to. func (a *app) homeAskNote(field []homeCellLine, width, room int) []homeCellLine { h := &a.home - if _, ok := a.homeAsking(); !ok || strings.TrimSpace(h.armed) == "" { + if _, ok := a.homeAsking(); !ok { return nil } - at := homeNoLine - for y := 0; y < room; y++ { - line := field[y].at - if line == homeNoLine || line < 0 || line >= len(h.lines) { - continue - } - if h.lines[line].kind == homeSession && strings.TrimSpace(h.lines[line].row.Transcript) == h.armed { - at = y - break + // THE CARD DRAWS WHEREVER THE QUESTION CAME FROM, and it must: the foot stops + // saying a question once this column can draw one ([app.sayHomeAsk]), so a + // question with no row to stand beside would be a decision on the screen with + // nothing on the screen about it. A question raised by `enter` has the row + // its key was pressed on ([homeView.armed]); one raised by the LAUNCH — a + // window that met a lock on the conversation it was opening — has no row at + // all, and stands at the top of the column instead. + at := 0 + if armed := strings.TrimSpace(h.armed); armed != "" { + for y := 0; y < room; y++ { + line := field[y].at + if line == homeNoLine || line < 0 || line >= len(h.lines) { + continue + } + if h.lines[line].kind == homeSession && strings.TrimSpace(h.lines[line].row.Transcript) == armed { + at = y + break + } } } - if at == homeNoLine { - return nil - } said := a.homeAskRows(max(1, width-homeGridLead)) if len(said) == 0 { return nil From 00f12c779a22d522a57482d9cd4c1aadd95343e7 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Tue, 15 Sep 2026 16:05:11 -0400 Subject: [PATCH 11/23] tui3: the ? leads the question, not the row that asked it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `GNeedsHuman` means "this has stopped and is waiting on you", and the thing that is true of is the QUESTION rather than the conversation it came from. While the question was the row's own second line the mark had nowhere else to be; now that the description column draws it, the mark goes with the words. So on a frame with that column the row keeps its two blank cells — every title on the screen still starts in the same column — and the note stands in the mark instead. Narrower than three columns nothing moves: the question is back under its row and the mark leads the row, as it always has. It is still ONE mark and one accent on the row's line (law 8), drawn through the vocabulary's one door, and iconlaw is green. Co-Authored-By: Claude Opus 5 --- internal/tui3/homecell.go | 22 +++++++++++++++- internal/tui3/homegrid_test.go | 46 ++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/internal/tui3/homecell.go b/internal/tui3/homecell.go index d57dca8b7d..8d30412176 100644 --- a/internal/tui3/homecell.go +++ b/internal/tui3/homecell.go @@ -172,7 +172,7 @@ func (a *app) homeDescNote(line homeLine, at int, said string, width int, select answers = strings.TrimSpace(a.homeRowAnswers(line, at)) } if line.cell.alwaysSaid() { - return []string{homeCellLeadBlank + switcherSides(room, said, answers, pal.dim, pal.muted)} + return []string{a.homeDescLead(line.cell, pal) + switcherSides(room, said, answers, pal.dim, pal.muted)} } var out []string for _, words := range wrap(said, room) { @@ -423,6 +423,16 @@ func (a *app) homeCellLead(cell *homeCell, at int, pal palette) string { if spin := a.homeSpinCell(at); spin != "" && cell.mark != cellMarkNeeds { return pal.accent(spin) + " " } + // THE MARK LEADS THE QUESTION AND NOT THE ROW where the description column + // draws that question ([homeDescNote]). The `?` means "this has stopped and + // is waiting on you", and the thing it is true of is the QUESTION — so on a + // frame that draws the question, the mark belongs beside the words rather + // than beside the title of the conversation they came from (owner, + // 2026-09-15). The row keeps the two blank cells, so every title on the + // screen still starts in the same column. + if cell.mark == cellMarkNeeds && homeDescOn(a.home.grid.cols) && cell.alwaysSaid() { + return homeCellLeadBlank + } switch cell.mark { case cellMarkNeeds: return pal.warn(pal.glyph(tokens.GNeedsHuman)) + " " @@ -635,3 +645,13 @@ func (a *app) homeAskNote(field []homeCellLine, width, room int) []homeCellLine } return out } + +// homeDescLead is what a note stands in: the row's own mark where the row gave +// it up ([app.homeCellLead]), and the same two blank cells otherwise — so a note +// with a mark and a note without one start their words in the same column. +func (a *app) homeDescLead(cell *homeCell, pal palette) string { + if cell.mark == cellMarkNeeds { + return pal.warn(pal.glyph(tokens.GNeedsHuman)) + " " + } + return homeCellLeadBlank +} diff --git a/internal/tui3/homegrid_test.go b/internal/tui3/homegrid_test.go index d282a8cbd9..21e1fe2b65 100644 --- a/internal/tui3/homegrid_test.go +++ b/internal/tui3/homegrid_test.go @@ -2,6 +2,7 @@ package tui3 import ( "fmt" + "github.com/Agent-Field/codeaf/internal/tui2/tokens" "strings" "testing" "time" @@ -280,6 +281,51 @@ func TestARaisedQuestionIsDrawnInTheDescriptionColumnAndNotOnTheFoot(t *testing. } } +// THE `?` LEADS THE QUESTION AND NOT THE ROW where the description column draws +// the question. The mark means "this has stopped and is waiting on you", and the +// thing that is true of is the question — so it stands beside the words rather +// than beside the title of the conversation they came from (owner, 2026-09-15). +// The row keeps its two blank cells, so every title still starts in the same +// column. +func TestTheNeedsMarkLeadsTheQuestionInTheColumnAndNotTheRow(t *testing.T) { + lab := newAnswerLab(t, consentQuestion(7, "needs your ok to run bash"), time.Now()) + a := lab.a + a.width, a.height = 180, 45 + homeText(a) + homeLineOf(t, a, func(l homeLine) bool { + return l.cell != nil && l.cell.panel == panelNeeds && l.cell.mark == cellMarkNeeds + }) + line, _ := a.home.focusedLine() + mark := a.pal.glyph(tokens.GNeedsHuman) + frame := homeText(a) + lines := strings.Split(frame, "\n") + + row, _ := homeRowOf(frame, line.cell.title) + if row < 0 { + t.Fatalf("the waiting row is not on the frame:\n%s", frame) + } + at := strings.Index(lines[row], mark) + if at < 0 { + t.Fatalf("the mark is nowhere on the row's line:\n%s", frame) + } + // IT IS ON THE QUESTION'S SIDE OF THE FRAME, not in the row's own lead. + said := strings.Index(lines[row], firstWordsOf(strings.TrimSpace(line.cell.sub))) + if said < 0 { + t.Fatalf("the question is not on the row's line:\n%s", frame) + } + if at > said || said-at > homeGridLead+1 { + t.Fatalf("the mark at %d does not lead the question at %d:\n%s", at, said, frame) + } + title := strings.Index(lines[row], line.cell.title) + if title >= 0 && at < title { + t.Fatalf("the mark at %d is still leading the row's title at %d:\n%s", at, title, frame) + } + // AND THERE IS STILL ONLY ONE OF IT (law 8). + if n := strings.Count(lines[row], mark); n != 1 { + t.Fatalf("the row's line wears %d marks, want one:\n%s", n, frame) + } +} + // firstWordsOf is enough of a sentence to find it on a frame that may have // wrapped the rest of it. func firstWordsOf(said string) string { From b36a97a5fff7a3222b47fa652fdf7f858b0f38c2 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Tue, 15 Sep 2026 16:07:26 -0400 Subject: [PATCH 12/23] tui3: a note in the description column stands in three cells, not two MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mark and the first letter of the sentence it leads were touching. A note's lead is one cell wider than a row's now, and EVERY note takes it whether it wears a mark or not — notes are read down the column against each other rather than against the rows in the column beside them, so the one that starts with a `?` and the one that starts with a word have to begin in the same place. Co-Authored-By: Claude Opus 5 --- internal/tui3/homecell.go | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/internal/tui3/homecell.go b/internal/tui3/homecell.go index 8d30412176..0992b34d5f 100644 --- a/internal/tui3/homecell.go +++ b/internal/tui3/homecell.go @@ -166,7 +166,7 @@ func (a *app) homeDescLines(width, room int, pal palette, field []homeCellLine) // rows above and below it and may not grow into them; only a note that is there // BECAUSE it is selected has the column to itself and wraps. func (a *app) homeDescNote(line homeLine, at int, said string, width int, selected bool, pal palette) []string { - room := max(1, width-homeGridLead) + room := max(1, width-homeDescLeadCells) answers := "" if selected { answers = strings.TrimSpace(a.homeRowAnswers(line, at)) @@ -176,10 +176,10 @@ func (a *app) homeDescNote(line homeLine, at int, said string, width int, select } var out []string for _, words := range wrap(said, room) { - out = append(out, homeCellLeadBlank+pal.dim(words)) + out = append(out, homeDescLeadBlank+pal.dim(words)) } if answers != "" { - out = append(out, "", homeCellLeadBlank+paintHint(answers, pal, pal.dim)) + out = append(out, "", homeDescLeadBlank+paintHint(answers, pal, pal.dim)) } return out } @@ -267,6 +267,15 @@ func (h *homeView) marksPanel(at int) bool { // homeCellLeadBlank is the lead of a row that wears no mark. var homeCellLeadBlank = strings.Repeat(" ", homeGridLead) +// homeDescLeadCells is what a note in the description column stands in, and it +// is ONE CELL WIDER THAN A ROW'S LEAD so the mark and the first letter of the +// sentence it leads are not touching (owner, 2026-09-15). Every note takes it, +// marked or not, because notes are read down the column against each other +// rather than against the rows in the column beside them. +const homeDescLeadCells = homeGridLead + 1 + +var homeDescLeadBlank = strings.Repeat(" ", homeDescLeadCells) + // homeCellHead is a panel's heading: its word in the places' one heading ink // (placeprose.go's [placeHeadingInk]), its explainer beside it dim, its clause at // the right margin dim. @@ -651,7 +660,7 @@ func (a *app) homeAskNote(field []homeCellLine, width, room int) []homeCellLine // with a mark and a note without one start their words in the same column. func (a *app) homeDescLead(cell *homeCell, pal palette) string { if cell.mark == cellMarkNeeds { - return pal.warn(pal.glyph(tokens.GNeedsHuman)) + " " + return pal.warn(pal.glyph(tokens.GNeedsHuman)) + strings.Repeat(" ", homeDescLeadCells-1) } - return homeCellLeadBlank + return homeDescLeadBlank } From dea2629774bd22a489655fd0462c16e07ec9e6ea Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Tue, 15 Sep 2026 16:14:00 -0400 Subject: [PATCH 13/23] demo-home: the fixture has been looked at before, so `since you left` has rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six of home's seven panels drew rows on the demo and `since you left` whispered — on a fixture holding eight landed tasks, three made files and a fortnight of memory changes. Nothing was missing from it. The look stamp was. THAT PANEL IS A DELTA AND A DELTA NEEDS AN ORIGIN. `.last-look` under `v3/projects` is when home was last closed, every `…Since` reader measures news from it, and a machine that has never had home closed on it has none — `ArtifactsSince` and `LandedSince` answer nothing at all rather than everything, which is look.go's stated law: "the first look marks NOTHING as news, rather than everything". Correct for a real first launch, and exactly wrong for a fixture whose whole job is a row on every place. So the seeder stamps it twelve hours back — the figure DESIGN.md §1 draws — far enough that the landed work and the made files are all inside the window. AND THE HANDOFF NOW SAYS WHY `running` NEEDS THE HEARTBEAT, which cost this session an hour of looking at an empty panel. It is `Presence.Fresh(now)` and nothing else, so a demo opened with a bare `HOME= bin/codeaf` shows it whispering however much work the fixture seeded: the rows are on disk and every reader has already given up on them. Through the seeder (`--launch`, which is what `make demo-home` uses) a beat restamps them and the panel fills. An empty `running` on the demo is a picture of the missing heartbeat, not of the panel. Co-Authored-By: Claude Opus 5 --- cmd/codeaf-demo-home/seed.go | 19 +++++++++++++++++++ docs/design/home-rethink/HANDOFF.md | 9 +++++++++ 2 files changed, 28 insertions(+) diff --git a/cmd/codeaf-demo-home/seed.go b/cmd/codeaf-demo-home/seed.go index c2f762cf08..e3d98ff8a3 100644 --- a/cmd/codeaf-demo-home/seed.go +++ b/cmd/codeaf-demo-home/seed.go @@ -134,9 +134,28 @@ func seedDemoHome(dir string, now time.Time) (builtHome, error) { built.UsageLines = lines built.Artifacts = writeArtifacts(filepath.Join(root, "v3", session.ArtifactsIndexName), projects, ids, now) + + // AND THE MACHINE HAS BEEN LOOKED AT BEFORE, half a day ago. + // + // Without this stamp `since you left` draws its whisper on a fixture full of + // landed work, and correctly: the look stamp is the ORIGIN news is measured + // from, and a machine that has never had home closed on it has no origin — + // "the first look marks NOTHING as news, rather than everything" + // (internal/session's look.go). A demo home with no stamp is therefore a + // demo home where one of the seven panels can never have a row, which is the + // one thing this fixture exists to prevent. + // + // TWELVE HOURS IS THE FIGURE THE DESIGN DRAWS (`since you left · 12h`, + // docs/design/home-mission-control/DESIGN.md §1) and it is far enough back + // that the landed work and the made files above it are all inside the + // window. + session.NoteLook(filepath.Join(root, "v3", "projects"), now.Add(-demoLastLook)) return built, nil } +// demoLastLook is how long ago this fixture's person last closed home. +const demoLastLook = 12 * time.Hour + // ── the projects ──────────────────────────────────────────────────────────── // demoProject is one workspace on the demo machine. diff --git a/docs/design/home-rethink/HANDOFF.md b/docs/design/home-rethink/HANDOFF.md index 3063946ab4..635fdb9ab0 100644 --- a/docs/design/home-rethink/HANDOFF.md +++ b/docs/design/home-rethink/HANDOFF.md @@ -44,6 +44,7 @@ comes back to the same home. It writes inside that directory and nowhere else | memory | 12 memories over all three shelves in `graph.db`, with varied use and miss counts and one let go | | spend | ~57 lines over 14 days across three models, bound to conversations, work and standing orders — every id joins to a row that is really there | | search | every turn of every conversation in the message index | +| home's `since you left` | a `.last-look` stamp twelve hours old under `v3/projects`, which is the ORIGIN news is measured from — without it that panel whispers on a fixture full of landed work, because a machine home has never been closed on has no origin and "the first look marks NOTHING as news" (`internal/session/look.go`) | | made for you | 3 deliverables, with the files behind them on the disk | ### The work one conversation still has out — the room, the jobs, the hostile names @@ -111,6 +112,14 @@ three heartbeats. So `--launch` runs a heartbeat beside the surface that restamp the seeding wrote, for exactly as long as the demo is open (`beat.go`). Without it the two clauses vanish before anybody has finished reading the screen. +**AND SO DOES HOME'S `running` PANEL, which is the reason this matters more than two +clauses.** That panel is `Presence.Fresh(now)` and nothing else (`homepanel_running.go`), +so a demo opened with a bare `HOME= bin/codeaf` shows it whispering however much work +the fixture seeded — the rows are on disk and every reader has given up on them. Open the +fixture through the seeder (`make demo-home DEMO_HOME= KEEP=1`, which is `--launch`) +and the panel fills. A screenshot of the demo with an empty `running` is a screenshot of +the missing heartbeat, not of the panel. + `TestTheDemoHomeFillsEveryPlace` reads all of it back through `session.ReadWorld`, `session.Peek`, `session.ReadTaskIndex`, the standing store, `store.MemorySnapshot`, `session.ReadUsage`, `Store.SearchConversations` and `session.ReadArtifacts`, and insists From f3fa007612be2975fbcca7c098448b010f3f9df5 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Tue, 15 Sep 2026 16:24:27 -0400 Subject: [PATCH 14/23] tui3: a question card is drawn whole or not at all, and the foot answers when it is not MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "What happens if we run out of vertical space?" — the card was CUT. At twelve rows its closing edge, its keys and one of its two answers were off the bottom of the screen, which is a decision a person cannot answer. A card taller than the room is not drawn now, and the one-line foot version — which exists for exactly the frames with no room for a card — takes it. Both sides ask ONE predicate ([app.homeAskFitsColumn]): two answers to "is the card on this frame" is a question drawn twice on a tall frame and drawn nowhere at all on a short one, and both of those happened while this was two conditions. THE FOOT ASKS IT AT DRAW TIME AND NOT ON A KEY. [app.sayHomeAsk] runs when a key is pressed, and somebody who has just made the window smaller has pressed none — so the card stopped fitting, the foot did not know, and the decision was on no part of the screen at all. [app.placeMsgLine] falls back to the question's own sentence whenever the card cannot be drawn, which a resize reaches on the next frame like everything else. AND THE CARD IS SIZED AGAINST THE COLUMN RATHER THAN THE FIELD'S OWN HEIGHT. It is not a note about a row and is not bounded by how many rows there are; clamping it to the field's length refused to draw one on every quiet machine, which is most of them. Co-Authored-By: Claude Opus 5 --- internal/tui3/homecell.go | 38 +++++++++++++++++++++-- internal/tui3/homeconfirm.go | 2 +- internal/tui3/homegrid_test.go | 56 ++++++++++++++++++++++++++++++++++ internal/tui3/pages.go | 10 ++++++ 4 files changed, 102 insertions(+), 4 deletions(-) diff --git a/internal/tui3/homecell.go b/internal/tui3/homecell.go index 0992b34d5f..4ea344274b 100644 --- a/internal/tui3/homecell.go +++ b/internal/tui3/homecell.go @@ -96,14 +96,20 @@ func (a *app) homeDescLines(width, room int, pal palette, field []homeCellLine) if room <= 0 || width <= homeGridLead { return nil } - room = min(room, len(field)) // A QUESTION HOME HAS RAISED TAKES THE WHOLE COLUMN, beside the row it is // about. One decision is drawn once and nothing is drawn beside it: the // column is otherwise a set of notes about rows, and notes stacked around a // question a person has to answer are the screen talking over it. + // + // IT IS ASKED BEFORE THE ROOM IS CLAMPED TO THE FIELD'S OWN HEIGHT. A card is + // not a note about a row and is not bounded by how many rows there are: a + // frame with two conversations on it has the whole column for the question, + // and clamping it to the field's length refused to draw one on every quiet + // machine. if rows := a.homeAskNote(field, width, room); rows != nil { return rows } + room = min(room, len(field)) preview := h.previewAt() type note struct { y int @@ -626,7 +632,7 @@ func (a *app) homeAskNote(field []homeCellLine, width, room int) []homeCellLine // all, and stands at the top of the column instead. at := 0 if armed := strings.TrimSpace(h.armed); armed != "" { - for y := 0; y < room; y++ { + for y := 0; y < min(room, len(field)); y++ { line := field[y].at if line == homeNoLine || line < 0 || line >= len(h.lines) { continue @@ -638,7 +644,13 @@ func (a *app) homeAskNote(field []homeCellLine, width, room int) []homeCellLine } } said := a.homeAskRows(max(1, width-homeGridLead)) - if len(said) == 0 { + // A CARD TALLER THAN THE ROOM IS NOT DRAWN AT ALL. Cutting it takes the + // closing edge, the keys and usually an answer off the bottom of the screen, + // and a decision whose answers a person cannot see is worse than the same + // decision said in one line on the foot — which is exactly what the foot + // version is for, and what [app.sayHomeAsk] falls back to when this refuses + // ([app.homeAskFitsColumn] is the one predicate both of them ask). + if len(said) == 0 || len(said) > room { return nil } top := min(at, max(0, room-len(said))) @@ -664,3 +676,23 @@ func (a *app) homeDescLead(cell *homeCell, pal palette) string { } return homeDescLeadBlank } + +// homeAskFitsColumn reports that a question home is holding can be drawn WHOLE +// in the description column of the frame as it last stood. +// +// IT IS THE ONE PREDICATE, asked by the column that draws the card and by the +// foot that would otherwise say it. Two answers to "is the card on this frame" +// is a question drawn twice on a tall frame and drawn nowhere at all on a short +// one, and both of those have happened. +func (a *app) homeAskFitsColumn() bool { + if _, ok := a.homeAsking(); !ok || !a.home.gridOn() || !homeDescOn(a.home.cols) { + return false + } + col := homeDescCol(a.home.cols) + _, widths := homeGridGeometry(a.home.gridWidth, a.home.cols) + if col < 0 || col >= len(widths) { + return false + } + said := a.homeAskRows(max(1, widths[col]-homeGridLead)) + return len(said) > 0 && len(said) <= a.home.room +} diff --git a/internal/tui3/homeconfirm.go b/internal/tui3/homeconfirm.go index 79a54cec68..b3a2c21628 100644 --- a/internal/tui3/homeconfirm.go +++ b/internal/tui3/homeconfirm.go @@ -159,7 +159,7 @@ func (a *app) sayHomeAsk() { // on one screen — the defect the whole block exists to end. Narrower than // that the grid has nowhere to put a card, so the foot is where the question // is said, as it is on every narrow frame. - if a.home.gridOn() && homeDescOn(a.home.cols) { + if a.homeAskFitsColumn() { return } if a.homeTierNow() != homeTierList && !a.home.gridOn() { diff --git a/internal/tui3/homegrid_test.go b/internal/tui3/homegrid_test.go index 21e1fe2b65..c8217dc4b6 100644 --- a/internal/tui3/homegrid_test.go +++ b/internal/tui3/homegrid_test.go @@ -326,6 +326,62 @@ func TestTheNeedsMarkLeadsTheQuestionInTheColumnAndNotTheRow(t *testing.T) { } } +// AND A FRAME TOO SHORT FOR THE CARD FALLS BACK TO THE FOOT rather than drawing +// a cut one. A card with its closing edge, its keys and usually an answer off +// the bottom of the screen is a decision a person cannot answer; the one-line +// foot version exists for exactly the frames with no room for a card, and this +// is one of them (owner, 2026-09-15: "what happens if we run out of vertical +// space?"). +func TestAFrameTooShortForTheQuestionCardSaysItOnTheFootInstead(t *testing.T) { + lab := newAnswerLab(t, consentQuestion(7, "needs your ok to run bash"), time.Now()) + a := lab.a + a.width, a.height = 180, 45 + homeText(a) + homeLineOf(t, a, func(l homeLine) bool { + return l.cell != nil && l.cell.panel == panelNeeds && l.kind == homeSession + }) + a.placeKeyPress(key("enter")) + ask, ok := a.homeAsking() + if !ok { + t.Fatal("enter raised no question") + } + head := strings.TrimSpace(ask.question.Head) + + // TALL: the card is in the column and the foot keeps its own sentence. + tall := homeText(a) + if !a.homeAskFitsColumn() { + t.Fatalf("the card does not fit a 45-row frame:\n%s", tall) + } + if row, _ := homeRowOf(tall, head); row < 0 { + t.Fatalf("the question is not in the column on a tall frame:\n%s", tall) + } + tallFoot := lastLineOf(tall) + if strings.Contains(tallFoot, head) { + t.Fatalf("the foot repeats a question the column drew: %q", tallFoot) + } + + // SHORT: no card at all, and the foot picks it up. + a.width, a.height = 180, 12 + short := homeText(a) + if a.homeAskFitsColumn() { + t.Fatalf("a 12-row frame claims room for the card:\n%s", short) + } + for _, line := range strings.Split(short, "\n") { + if strings.Contains(line, "╭") || strings.Contains(line, "╰") { + t.Fatalf("a cut card was drawn on a frame with no room for it:\n%s", short) + } + } + if foot := lastLineOf(short); !strings.Contains(foot, head) { + t.Fatalf("the short frame says the question nowhere; its foot is %q:\n%s", foot, short) + } +} + +// lastLineOf is a frame's foot. +func lastLineOf(frame string) string { + lines := strings.Split(frame, "\n") + return lines[len(lines)-1] +} + // firstWordsOf is enough of a sentence to find it on a frame that may have // wrapped the rest of it. func firstWordsOf(said string) string { diff --git a/internal/tui3/pages.go b/internal/tui3/pages.go index 5fc2b0288c..81bdcff322 100644 --- a/internal/tui3/pages.go +++ b/internal/tui3/pages.go @@ -2089,6 +2089,16 @@ func (a *app) placeMsgLine(width int) (string, bool) { if msg == "" && a.at(pageHome) { msg, path = a.home.msg, a.home.msgPath } + // AND A QUESTION WITH NOWHERE ELSE TO GO IS ASKED HERE, AT DRAW TIME. The + // grid draws a raised question as a card in its description column and the + // foot stays quiet ([app.homeAskFitsColumn]); a frame too short or too narrow + // for that card has to say it, and it cannot wait for the next keystroke to + // find out — [app.sayHomeAsk] runs on a key, and a person who made the window + // smaller has pressed none. That left the decision on no part of the screen + // at all, which is the one state a question may never be in. + if msg == "" && a.at(pageHome) && !a.homeAskFitsColumn() { + msg = a.homeAskFoot() + } if msg == "" { return "", false } From dd347ec19e6ee5672d9e135c028cbf73c10436ca Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Tue, 15 Sep 2026 19:09:05 -0400 Subject: [PATCH 15/23] =?UTF-8?q?tui3:=20needs=20you=20is=20its=20words=20?= =?UTF-8?q?alone=20=E2=80=94=20no=20counts,=20no=20clause,=20and=20the=20l?= =?UTF-8?q?andings=20are=20unread?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The heading counted its live questions and the group line under it counted the landings, with `finished, nobody has checked it` at the line's right. The owner cut all three: a figure beside rows that are right there is a second thing to read that says nothing the rows do not. The group is `unread` now, the one word a mailbox uses, and its line is that word. The fold keeps its count, because it stands for rows that are not on the screen. The change entry also takes in the rest of the branch — the description column, the moved mark, the question card, enter on standing and the resting caret — which it had not yet recorded. Co-Authored-By: Claude Fable 5.1 --- cmd/codeaf-demo-home/seed_work.go | 2 +- .../unreleased/1046-home-field-and-rail.md | 15 ++++++ docs/design/home-mission-control/DESIGN.md | 10 ++-- internal/manual/chat/home.md | 34 ++++++------- internal/manual/chat/keys.md | 2 +- internal/tui3/homeband_answer.go | 2 +- internal/tui3/homecell.go | 5 +- internal/tui3/homegrid.go | 27 +++++------ internal/tui3/homeneedsgroups_test.go | 14 ++++-- internal/tui3/homepanel_left.go | 4 +- internal/tui3/homepanel_live_test.go | 33 ++++++++----- internal/tui3/homepanel_needs.go | 48 ++++++++++--------- internal/tui3/homepanel_test.go | 4 +- internal/tui3/placeprose.go | 26 +++++----- internal/tui3/pulse.go | 4 +- 15 files changed, 127 insertions(+), 103 deletions(-) diff --git a/cmd/codeaf-demo-home/seed_work.go b/cmd/codeaf-demo-home/seed_work.go index 2f74a7d270..a31d8f37c9 100644 --- a/cmd/codeaf-demo-home/seed_work.go +++ b/cmd/codeaf-demo-home/seed_work.go @@ -183,7 +183,7 @@ var demoTasks = []demoTask{ }, }, // 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), + // `needs you` panel holds them as its `unread` 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 diff --git a/docs/changes/unreleased/1046-home-field-and-rail.md b/docs/changes/unreleased/1046-home-field-and-rail.md index 323c621d27..39958a79cf 100644 --- a/docs/changes/unreleased/1046-home-field-and-rail.md +++ b/docs/changes/unreleased/1046-home-field-and-rail.md @@ -8,6 +8,12 @@ invalidates: - "Home's law 3 said `a panel keeps its column and its rank in it`. The column half is struck. The rank half stands and is what keeps the cost to one axis: inside the field and inside the rail the order is still the order table's, so two panels that both fill never swap places. Anything that remembers where a named panel is drawn is wrong; what can still be relied on is the order, and that `projects` and `spend` are PINNED to the top of the rail whatever they hold, with one blank row under them separating what lives there from what is only quiet today." - "The three-column tier put `projects` and `spend` in a column of their own and spread the other five over the first two. No longer true: the rail is the last column at both the two- and three-column tiers, and THE FIELD FILLS RATHER THAN BALANCES — a frame whose panels fit in one column leaves the middle of a wide screen empty instead of spreading two short columns over it. The empty middle is the ruling and not a gap in it." - "`←→` crossed to the NEIGHBOURING column and did nothing where that column had no row to stand on. No longer true: the arrow now steps over a column with nothing in it to the next one that has a row. Without it the rail would be out of the arrows' reach entirely on a wide quiet frame — `projects` and `spend` drawn on the screen with no key that walks to them." + - "`needs you` counted its live questions on its heading (`needs you · 2`) and its landings on the group line under them (`to check · 8`), with the clause `finished, nobody has checked it` at that line's right. No longer true: the heading is the two words alone, the group is called `unread`, and its line is that one word — no count, no clause. The rows under the words are the count. The one count left in the panel is the fold's, `3 unread · tasks` and `N older · tasks`, which stands for rows that are NOT on the screen. Anything waiting for the string `to check` on home — a test needle, a manual page — is looking for a word that is gone." + - "The `?` on a `needs you` row stood in the row's own lead cell, left of its title. On a frame wide enough for the description column it stands at the head of the row's description in that column, one space before the sentence, and the row's lead is blank; on a narrower frame it is where it was." + - "A row's description — the question a conversation stopped on, the first sentence of where a conversation left off, what a standing order is about to do — was a second line UNDER the row, so a panel grew and shrank as the cursor walked. On a frame of three columns or more it is drawn in the middle column, level with its row, running down to the next row that has one; a permanent description (a `needs you` question) is always there and a cursor-only one (`where you were`, `next up`) appears beside the row the cursor is on. The middle column is no longer free for a third panel of headings; it is the descriptions' and nothing else stands in it. Two-column frames keep the second-line shape." + - "A question raised from home — the consent card, a standing order's question — was said on the foot line and answered there. On a three-column frame it is drawn as a card in the middle column beside the row that raised it, WHOLE OR NOT AT ALL: a frame too short for the card falls back to the foot's one-line version, and the two are never on one frame together." + - "`enter` on a `needs you` watch with no conversation behind it did nothing and said nothing. It opens the item on the standing place, and the row's legend says `enter on standing` so the key is not a surprise. `enter` on a landing in another project opens it — the guard that refused a bucket other than this window's is gone." + - "Home's `say what you want done` line drew no cursor at rest. It carries the same cursor a conversation's box does." - "Home's resting foot was `type to search or start something new · ↑↓ pick · enter open · tab next place` on a quiet machine. It now carries the chord too — `… · enter open · ctrl+o open folder · tab next place`. The clause was always there on a machine whose right column had rows, because §6.6 refuses to leave a door invisible when `→` crosses columns rather than opening a row's verbs; what changed is that the rail always has rows now, so the condition is always met. The design's four keys still open the sentence and `esc` is still not on it." --- @@ -27,3 +33,12 @@ axis and not to both. `docs/design/home-mission-control/DESIGN.md` carries the amended laws 2, 3 and 6 and says what each of them used to say. + +The same branch took the empty middle that ruling left and gave it to the +descriptions: the sentence under a row moved beside it, the `?` moved to the +head of that sentence, and the question card went there too, sized against the +column and giving way whole to the foot on a short frame. And it pruned the +`needs you` panel to its words — no count on the heading, no count or clause on +the group line, and `unread` for `to check`, on the owner's ruling that a figure +beside rows that are right there is a second thing to read that says nothing +the rows do not. diff --git a/docs/design/home-mission-control/DESIGN.md b/docs/design/home-mission-control/DESIGN.md index 0d1d819512..803737637e 100644 --- a/docs/design/home-mission-control/DESIGN.md +++ b/docs/design/home-mission-control/DESIGN.md @@ -24,7 +24,7 @@ own keys, each a door to the place that owns it. The chat list is one panel. home tasks spend settings ─────────────────────────────────────────────────────────────────────────────────────── - needs you · 2 projects + needs you projects ? Searching for Apartments Near Minto 2h ~/codeaf 12 chats · 1 running master, 2 dirty needs your ok to run bash 1 yes 2 not now ~/pricing-site 5 chats · a job up main ? Clever Bet Prediction Model 6h ~/infra 3 chats · quiet 4d @@ -100,9 +100,9 @@ is the reading that produced the 2026-09-15 amendment. 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 · `. 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 + rows of `needs you`'s `unread` 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). + group's line goes and the fold names it — `8 unread · 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. @@ -113,7 +113,7 @@ is the reading that produced the 2026-09-15 amendment. 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. The one exception is a landing in `unread`, 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 @@ -121,7 +121,7 @@ is the reading that produced the 2026-09-15 amendment. `session.LandingYesKey`/`LandingNoKey` before the answer leaves the surface. 8. **Two marks, one accent, one hint line.** `GlyphNeedsHuman` in `hueWarn` for 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 + `unread`, 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 eb1502346c..e86b8d5a66 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -11,13 +11,13 @@ answering one question you would ask walking up to a colleague's desk: home tasks spend settings ─────────────────────────────────────────────────────────────────────────────────────── - needs you · 2 projects · folders you've opened + needs you projects · folders you've opened ? Searching for Apartments Near Minto 2h ~/codeaf 12 chats · 1 running master needs your ok to run bash 1 allow once 2 always ~/pricing-site 5 chats ? Clever Bet Prediction Model 6h the 2024 season only, or all three? enter spend today $0.14 of $20 ▁▂▁▃▅▂▁▁▇▃▂▅▂▁ 14 days $34.10 - to check · 3 finished, nobody has checked it opus 63% · 3 chats and 1 task today + unread opus 63% · 3 chats and 1 task today tier-B subs 3 files · 1d built the tier-B flow 1 accept 2 not right package release radar 1d @@ -87,7 +87,7 @@ at the left is that your eye has a single place to go. | Panel | What a row is | `enter` on a row | Dim line when it holds nothing | | --- | --- | --- | --- | -| `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` | +| `needs you` | a question waiting on a person, anywhere, and under them the `unread` 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` | @@ -116,9 +116,9 @@ something has stopped and cannot go on without you, from any project: - a reminder, watch or rule that stopped and wants an answer. 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. +what it is asking, in the question's own words. **The longest wait is at the top.** The +heading is the two words alone — it carries no count, whether two things are stopped or +none; the rows under it are the count. **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 @@ -130,14 +130,14 @@ 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. -## to check — the group of landings nobody has checked +## unread — the group of landings nobody has checked, work that finished and wants a look -**`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: +**`unread` 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, and the line is the +one word — no count after it, no clause at its right: ``` - to check · 8 finished, nobody has checked it + unread tier-B subs 3 files · 1d package release radar 1d ``` @@ -173,11 +173,11 @@ panel's last line counts it instead — `3 older · tasks` — and `enter` there 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 +**A landing on `unread` 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. +`8 unread · tasks`, which opens the tasks place. ## Answer from home — a digit answers the question that is drawing its answers @@ -188,12 +188,12 @@ screen twice with two meanings. The words are that question's own and nothing he 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 +conversation stops on. The one place they are not is a landing in `unread`: 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. +walked onto a landing in `unread` 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 @@ -216,7 +216,7 @@ 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 -`to check` row opens the conversation that ran the work, on the task's record, where the +`unread` 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. @@ -241,7 +241,7 @@ 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 codeaf decide this one`; -- **home** — walk onto its row in `to check` and press `1` or `2`. The conversation applies +- **home** — walk onto its row in `unread` 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 moment that conversation next opens — the constructor drains the doorstep before anything else — and the row says `answered · waiting for it to pick that up` meanwhile; diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index 9d0fcb0c77..63f17afae6 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -2004,7 +2004,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, 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 | +| 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 `unread` 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/tui3/homeband_answer.go b/internal/tui3/homeband_answer.go index 3e75ac4287..e3c8f85a4b 100644 --- a/internal/tui3/homeband_answer.go +++ b/internal/tui3/homeband_answer.go @@ -129,7 +129,7 @@ func (a *app) answerSent(row session.SessionRow, question session.PresenceQuesti // // 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 +// `unread` 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. diff --git a/internal/tui3/homecell.go b/internal/tui3/homecell.go index 4ea344274b..fb9d8a54d0 100644 --- a/internal/tui3/homecell.go +++ b/internal/tui3/homecell.go @@ -383,9 +383,8 @@ 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]). +// homeCellGroup is a group's own line inside a panel: its word, 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 diff --git a/internal/tui3/homegrid.go b/internal/tui3/homegrid.go index b8c7f33ca8..82f2aadc5b 100644 --- a/internal/tui3/homegrid.go +++ b/internal/tui3/homegrid.go @@ -123,9 +123,7 @@ type homePanelSlot struct { // beside the heading drops it rather than cutting the heading for it // ([homeCellHead]). A panel has none where the heading already says what it // is: spend carries its right-hand money clause, and needs you carries its - // own group line (`to check · 2 — finished, nobody has checked it`) — and its - // heading is the one that counts live questions, so a gloss there would - // repeat the panel and stack a third clause on the count. + // own group line (`unread`) — so a gloss there would repeat the panel. explainer string // pinned is a panel that stands in the rail whatever it holds. It is for a // panel whose height is the same on every machine on every day — spend is @@ -294,7 +292,7 @@ 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` + // calls is every landing whose check is the person's, as the `unread` // 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 @@ -387,12 +385,12 @@ 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 is the group's name — the whole of its line, and the word the + // panel's fold uses instead of `more` while the group is shut. A group line + // carries no count and no clause: the rows are under it, and the fold counts + // what it hides (owner, 2026-09-15; it used to say `to check · 8` with + // `finished, nobody has checked it` at its right). 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. @@ -406,8 +404,7 @@ const ( 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. + // the left ([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 @@ -509,7 +506,7 @@ func (l homeLine) height() int { // purpose is that a person reads what is waiting on them WITHOUT walking the // cursor onto it. A question you must select to read is a question you can miss. // -// It is the permanent one only. A `to check` landing in the same panel grows its +// It is the permanent one only. A `unread` landing in the same panel grows its // sentence under the cursor and has never been readable at a glance, so it is // drawn only while it is the selected row, like every other row's. func (c *homeCell) alwaysSaid() bool { @@ -772,7 +769,7 @@ func growColumn(column []*homeGridPanel, room int) { // 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 +// with the last of them — the `unread` landings fold before a `needs you` row // is given up, without this function knowing that either exists. func (p *homeGridPanel) shrink() { for p.shown > 0 && p.height() > p.slot.least { @@ -976,7 +973,7 @@ func (p homeGridPanel) lines() []homeLine { 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}}) + panel: id, title: group.word}}) out = append(out, p.read.lines[group.at:p.shown]...) } else { out = append(out, p.read.lines[:p.shown]...) @@ -1017,7 +1014,7 @@ 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 +// the screen — `8 unread · 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 diff --git a/internal/tui3/homeneedsgroups_test.go b/internal/tui3/homeneedsgroups_test.go index c63dc27a95..4071781d2f 100644 --- a/internal/tui3/homeneedsgroups_test.go +++ b/internal/tui3/homeneedsgroups_test.go @@ -137,7 +137,7 @@ func TestADigitStillReachesTheTopQuestionFromALandingThatCannotAnswer(t *testing } // 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 +// `unread` is showing, and shows it again once the landing has aged out of the // group. func TestSinceYouLeftOmitsALandingToCheckIsShowing(t *testing.T) { l := newLiveLab(t) @@ -183,8 +183,12 @@ func TestToCheckFoldsBeforeANeedsYouRowGoes(t *testing.T) { 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) + // The group's own line is the word on a line of its own; the fold that + // stands for it says `3 unread · tasks`, and only the fold may be here. + for _, row := range strings.Split(frame, "\n") { + if strings.TrimSpace(row) == needsCheckWord { + 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) @@ -238,14 +242,14 @@ func TestAPausedRunKeepsItsRowBesideItsOwnLanding(t *testing.T) { } // WORK IN A CONVERSATION SOMEBODY PUT AWAY IS NOT WAITING ON THEM: no row on -// `to check`, and nothing in the count. +// `unread`, 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) + t.Fatalf("an archived conversation's landing is on unread: %+v", rows) } if a.machine.wants != 0 { t.Fatalf("the pulse counts an archived conversation's landing: %d", a.machine.wants) diff --git a/internal/tui3/homepanel_left.go b/internal/tui3/homepanel_left.go index 7b477d55c4..e14f061f2d 100644 --- a/internal/tui3/homepanel_left.go +++ b/internal/tui3/homepanel_left.go @@ -23,7 +23,7 @@ import ( 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 +// drawn by `needs you`'s `unread` 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 @@ -56,7 +56,7 @@ func leftLine(row switcherRow) homeLine { // 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` +// here because three readers ask it — this panel's own key, the `unread` // 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 { diff --git a/internal/tui3/homepanel_live_test.go b/internal/tui3/homepanel_live_test.go index 2ed22ce8bc..cf991df11e 100644 --- a/internal/tui3/homepanel_live_test.go +++ b/internal/tui3/homepanel_live_test.go @@ -115,12 +115,14 @@ func TestNeedsYouOrdersTheWaitsAndDrawsAnswersOnTheTopRowOnly(t *testing.T) { if under := homeLineAfter(frame, "Prime Sieve"); strings.Contains(under, "allow once") || !strings.Contains(under, "enter") { t.Fatalf("the second row drew a second set of answers:\n%s", frame) } - if !strings.Contains(frame, "needs you · 2") { - t.Fatalf("the heading does not count what waits:\n%s", frame) + // AND THE HEADING IS THE WORD ALONE. It used to count the questions + // (`needs you · 2`); the rows are under it (owner, 2026-09-15). + if !strings.Contains(frame, "needs you") || strings.Contains(frame, "needs you · ") { + t.Fatalf("the heading counts its rows:\n%s", frame) } } -// A TASK THE RECORD MARKS AS YOUR CALL IS A ROW OF THE `to check` GROUP, one +// A TASK THE RECORD MARKS AS YOUR CALL IS A ROW OF THE `unread` 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) { @@ -137,8 +139,12 @@ func TestNeedsYouCarriesATaskWaitingOnYourCall(t *testing.T) { 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) + // THE GROUP LINE IS THE WORD ALONE — no count after it and no clause at its + // right (owner, 2026-09-15; it used to say `to check · 1` and `finished, + // nobody has checked it`). + if !strings.Contains(frame, needsCheckWord) || strings.Contains(frame, needsCheckWord+" · ") || + strings.Contains(frame, "nobody has checked") { + t.Fatalf("the group line does not name the group, or says more than its name:\n%s", frame) } if strings.Contains(frame, "landed unchecked") { t.Fatalf("the retired sub-line is still drawn:\n%s", frame) @@ -157,7 +163,7 @@ func TestToCheckDrawsNoGroupLineWithoutLandings(t *testing.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) { + if frame := homeText(a); strings.Contains(frame, needsCheckWord) { t.Fatalf("a group with no rows drew its line:\n%s", frame) } for _, line := range panelLines(a, panelNeeds) { @@ -203,7 +209,7 @@ func TestToCheckDrawsTheNewestLandingFirst(t *testing.T) { } 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) + t.Fatalf("unread is not newest first: %+v", rows) } } @@ -284,14 +290,17 @@ func TestNeedsYouAgesOldCallsOntoTheFold(t *testing.T) { t.Fatalf("needs you is not the two fresh calls: %+v", rows) } frame := homeText(a) - // 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. + // AND NEITHER THE HEADING NOR THE GROUP LINE COUNTS ANYTHING: the rows are + // under them. The one count is the fold's, for the aged landings that are + // not on the screen. 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) + if !strings.Contains(frame, needsCheckWord) || strings.Contains(frame, needsCheckWord+" · ") { + t.Fatalf("the group line says more than its name:\n%s", frame) + } + if !strings.Contains(frame, "3 older · tasks") { + t.Fatalf("the fold does not count what aged:\n%s", frame) } } diff --git a/internal/tui3/homepanel_needs.go b/internal/tui3/homepanel_needs.go index f5973407ba..a4ae670e4c 100644 --- a/internal/tui3/homepanel_needs.go +++ b/internal/tui3/homepanel_needs.go @@ -19,16 +19,16 @@ import ( // 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, +// Under them the `unread` 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). +// THE DIFFERENCE BETWEEN THE TWO GROUPS IS SAID ONCE, ON THE GROUP'S OWN LINE, +// and it is one word: `unread` (placeprose.go's [needsCheckWord]). 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); then as a +// clause at the group line's right, which the owner cut on 2026-09-15. // // 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 @@ -48,7 +48,7 @@ const ( // 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 + // there the answers are the thing the line is for; a `unread` 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]). @@ -85,15 +85,17 @@ func (needsPanel) rows(in *homeGridInput) homePanelRows { lines = append(lines, item.line) } out := homePanelCut(panelNeeds, lines) - // 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)) + // NEITHER THE HEADING NOR THE GROUP LINE CARRIES A COUNT. The heading used + // to count the live questions (`needs you · 2`) and the group line the + // landings (`to check · 8`); the owner cut both on 2026-09-15, because the + // rows are right there under the words and a figure beside them is a second + // thing to read that says nothing the rows do not. The fold still counts what + // it hides (`3 unread · tasks`), which is the one place a count stands for + // rows that are NOT on the screen. The aged-out landings are counted on the + // same fold ([needsFresh]). out.older = in.callsOlder if len(calls) > 0 { - out.group = &homePanelGroup{at: len(asked), word: needsCheckWord, - said: countWord(len(calls)), right: needsCheckClause} + out.group = &homePanelGroup{at: len(asked), word: needsCheckWord} } return out } @@ -102,7 +104,7 @@ func (needsPanel) rows(in *homeGridInput) homePanelRows { // history. // // A YOUR-CALL OLDER THAN [homeNeedsTaskFresh] IS HISTORY, NOT A QUESTION (owner, -// 2026-09-10: a machine with twenty-four week-old landings drew `needs you · 24` +// 2026-09-10: a machine with twenty-four week-old landings drew twenty-four rows // over rows nobody was going to answer, and a live question arriving under them // would have been the twenty-fifth). They stay one door away — the fold counts // them into the tasks place, where every one of them still is. Only a task's @@ -132,7 +134,7 @@ func needsAged(asked, now time.Time) bool { // 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 ` into the presence with no question -// object, so such a session drew as a question ABOVE the `to check` row for the +// object, so such a session drew as a question ABOVE the `unread` 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 @@ -185,7 +187,7 @@ func needsAsked(in *homeGridInput) []needsItem { 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 +// on is one of its own landings, which the `unread` 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 @@ -214,7 +216,7 @@ func needsLandingsSpeakFor(row session.SessionRow, titles []string) bool { return false } -// needsCallTitlesOn is the titles the `to check` group is drawing for one +// needsCallTitlesOn is the titles the `unread` 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 @@ -227,7 +229,7 @@ func needsCallTitlesOn(in *homeGridInput, id string) []string { } // 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 +// landing of its own that the `unread` 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 @@ -242,7 +244,7 @@ func needsWants(row session.SessionRow, now time.Time) int { return n } -// needsCallTitles is one conversation's landings as the `to check` group would +// needsCallTitles is one conversation's landings as the `unread` group would // draw them, by title, taken straight off the index row. func needsCallTitles(row session.SessionRow, now time.Time) []string { if row.Archived { @@ -287,7 +289,7 @@ func needsCalls(world session.World, now time.Time) []needsItem { // 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 +// THREE READERS AND ONE ANSWER. The `unread` 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 @@ -433,7 +435,7 @@ func needsLandingKey(key string) (string, bool) { return "", false } -// needsChecking is every landing the `to check` group is drawing, by the +// needsChecking is every landing the `unread` 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. @@ -507,7 +509,7 @@ func answersWord(question session.PresenceQuestion) string { // ── answering a landing from home ─────────────────────────────────────────── -// needsLandingQuestion is the landing question a `to check` row stands for, as +// needsLandingQuestion is the landing question a `unread` 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. diff --git a/internal/tui3/homepanel_test.go b/internal/tui3/homepanel_test.go index f0493f289c..d46a79b11e 100644 --- a/internal/tui3/homepanel_test.go +++ b/internal/tui3/homepanel_test.go @@ -31,8 +31,8 @@ func TestNeedsYouCarriesTheQuestionAndItsAnswersOnTheRow(t *testing.T) { if !strings.Contains(under, "needs your ok to run bash") || !strings.Contains(under, "1 allow once") { t.Fatalf("the row does not carry its question and answers:\n%s", frame) } - if !strings.Contains(frame, "needs you · 1") { - t.Fatalf("the heading does not count what is waiting:\n%s", frame) + if !strings.Contains(frame, "needs you") || strings.Contains(frame, "needs you · ") { + t.Fatalf("the heading counts what is waiting, and it should be the word alone:\n%s", frame) } } diff --git a/internal/tui3/placeprose.go b/internal/tui3/placeprose.go index 32d641405d..79ea1c0166 100644 --- a/internal/tui3/placeprose.go +++ b/internal/tui3/placeprose.go @@ -96,21 +96,19 @@ const ( standNotOursWord = "that one does not stand over this conversation" ) -// 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. +// needsCheckWord is the name of the `unread` group of `needs you` +// (homepanel_needs.go), spelled once here because the group line, the fold and +// the manual all quote it. // -// 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 WORD IS THE WHOLE EXPLANATION. The difference between a row that has +// stopped a conversation and a landing nobody has looked at used to be spelled +// under every landing — `landed unchecked · enter to look`, the same sub-line on +// nine rows (owner, 2026-09-11) — then once, as a clause at the group line's +// right margin (`finished, nobody has checked it`). The owner cut the clause on +// 2026-09-15: `unread` says it in one word, the way a mailbox does, and the +// group line carries neither a clause nor a count. The rows under it are one +// line each. +const needsCheckWord = "unread" // ── THE FIVE-LEVEL SCALE (SCREEN 2a) ──────────────────────────────────────── // diff --git a/internal/tui3/pulse.go b/internal/tui3/pulse.go index 17f2801f7d..7fd8b8471c 100644 --- a/internal/tui3/pulse.go +++ b/internal/tui3/pulse.go @@ -118,8 +118,8 @@ const ( // chat nothing else on the frame says that two things have stopped on you. pulseWhole pulseMode = iota // pulseBudget is the budget and the clock alone, and it is home's. HOME IS - // THE SUMMARY OF THE TABS and its panels ARE the counts — `needs you · 2` - // over the rows that need you — so a `2 want you` above them would be the + // THE SUMMARY OF THE TABS and its panels ARE the counts — `needs you` over + // the very rows that need you — so a `2 want you` above them would be the // same news said twice on one frame (DESIGN.md's laws 10 and 11). pulseBudget ) From 8d2362f8247ed85df8af43c809e9227ec9ebfc31 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Tue, 15 Sep 2026 19:26:53 -0400 Subject: [PATCH 16/23] tui3: every row of home's field rests on the one foot, and ctrl+o is true on all of them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The foot under a field row said that row's own thing — `ctrl+x stop it` on a running task, `enter open where it was asked · ctrl+e pause · esc close` on a standing order, `enter opens the place this happened in · esc close` on a line of news — so it changed under the hand on every step of the cursor. The owner ruled that the rows under the moving headings all rest on the resting sentence: the four keys and `ctrl+o open folder`. For the chord to be true on every row, every row answers what folder it belongs to: a standing order's workspace, the conversation a landing ran in. The pause and stop chords still work and are on the alt+. map; the foot no longer names them, which also spares the paint the running-verbs walk the frame-disk law had to write around. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- .../unreleased/1046-home-field-and-rail.md | 1 + internal/manual/chat/home.md | 32 ++++++---- internal/manual/chat/keys.md | 12 ++-- internal/tui3/home.go | 31 +++++++-- internal/tui3/homegrid.go | 58 ++++++++++------- internal/tui3/homegrid_test.go | 63 ++++++++++++++++++- internal/tui3/homepanel_live_test.go | 15 ++--- 7 files changed, 162 insertions(+), 50 deletions(-) diff --git a/docs/changes/unreleased/1046-home-field-and-rail.md b/docs/changes/unreleased/1046-home-field-and-rail.md index 39958a79cf..e231161b58 100644 --- a/docs/changes/unreleased/1046-home-field-and-rail.md +++ b/docs/changes/unreleased/1046-home-field-and-rail.md @@ -14,6 +14,7 @@ invalidates: - "A question raised from home — the consent card, a standing order's question — was said on the foot line and answered there. On a three-column frame it is drawn as a card in the middle column beside the row that raised it, WHOLE OR NOT AT ALL: a frame too short for the card falls back to the foot's one-line version, and the two are never on one frame together." - "`enter` on a `needs you` watch with no conversation behind it did nothing and said nothing. It opens the item on the standing place, and the row's legend says `enter on standing` so the key is not a surprise. `enter` on a landing in another project opens it — the guard that refused a bucket other than this window's is gone." - "Home's `say what you want done` line drew no cursor at rest. It carries the same cursor a conversation's box does." + - "The foot under a field row said that row's own thing: `ctrl+x stop it` on a running task this window holds, `enter open where it was asked · ctrl+e pause · esc close` on a standing order, `enter opens the place this happened in · esc close` on a `since you left` line, and the resting sentence with `ctrl+o open folder` on a conversation. No longer true on the grid: EVERY row under a moving heading — `needs you` and `unread`, `where you were`, `running`, `since you left`, `next up` — rests on the one sentence, `type to search or start something new · ↑↓ pick · enter open · ctrl+o open folder · tab next place`, and `ctrl+o` is true on all of them because every such row belongs to a folder — an order's workspace, the conversation a landing ran in. `ctrl+e` and `ctrl+x` still work and are on the `alt+.` map; the foot no longer names them. The phone's list and the filtered list keep the rows' own sentences." - "Home's resting foot was `type to search or start something new · ↑↓ pick · enter open · tab next place` on a quiet machine. It now carries the chord too — `… · enter open · ctrl+o open folder · tab next place`. The clause was always there on a machine whose right column had rows, because §6.6 refuses to leave a door invisible when `→` crosses columns rather than opening a row's verbs; what changed is that the rail always has rows now, so the condition is always met. The design's four keys still open the sentence and `esc` is still not on it." --- diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index e86b8d5a66..94093465b3 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -49,11 +49,15 @@ in the right-hand rail instead — heading and one dim line each — so the left is only ever the things that are actually going on. `esc` puts you back in exactly the chat you came from, untouched — nothing was closed and -nothing was sent while you were looking. The resting foot reads `type to search or start -something new · ↑↓ pick · enter open · ctrl+o open folder · tab next place` — the chord is -there because `→` on a field row crosses to the rail rather than opening the row's verbs, and -a door you cannot see is a door you never learn. `alt+.` draws the whole map over the cells -you are already reading. +nothing was sent while you were looking. The foot reads `type to search or start +something new · ↑↓ pick · enter open · ctrl+o open folder · tab next place`, **and it is the +same sentence on every row of the field** — a conversation, a question, a running task, a +standing order on `next up`, a line of `since you left`. It does not change as the cursor +walks, so it is something you read once. The chord is there because `→` on a field row +crosses to the rail rather than opening the row's verbs, and a door you cannot see is a door +you never learn; `ctrl+o` opens the folder each row belongs to — a conversation's workspace, +the workspace an order stands over, the conversation a landing ran in. `alt+.` draws the +whole map over the cells you are already reading. There is no argument form. The screen is how you name what you want; a command that took a project name would be asking you to type out the very thing home exists to show you. @@ -289,8 +293,9 @@ column or `◐` mark any more; this panel is that list. conversation holds offers `s stop it`, which asks you first, and **`ctrl+x`** asks the same from any column with no strip. A `running` row with rows in it is in the field, and the rail is always a column with rows to its right — so `→` crosses to the rail instead of opening -the verbs, and the foot names the chord: `ctrl+x stop it`. Where the column the arrow would -reach is empty, it steps over it to the next one that has a row. **A task another window runs cannot be stopped from here** — the row +the verbs; `ctrl+x` still asks, and the foot does not name it — the foot under every row of +the field is the one resting sentence, and the stop is on the `alt+.` map. Where the column +the arrow would reach is empty, it steps over it to the next one that has a row. **A task another window runs cannot be stopped from here** — the row offers no stop at all; bring it here with `enter` and stop it there. The pulse inside a chat counts the same work: `1 moving` is these rows. @@ -476,9 +481,12 @@ while the pointer is on it. **Columns win the arrow: `→` opens a row's verbs only where no column with rows lies to its right** — on the rail, and everywhere at one column. Where the next column along is empty the arrow steps over it to the next one that has a row, so the rail is always reachable. On a row where `→` crosses -instead, **the foot names the chord that still reaches them** — `ctrl+o open folder`, or -`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. +instead, **the foot names the one chord that is true on every row** — `ctrl+o open folder`, +which opens a conversation's workspace, the workspace a standing order stands over, or the +conversation a `since you left` line happened in — and the rest are on their chords too: +`ctrl+y` copy path, `ctrl+e` put away or pause, `ctrl+x` stop. The foot does not change +from row to row; `alt+.` draws the map when you want the rest. `ctrl+o` on a `projects` row +opens that project's folder. 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. @@ -1869,8 +1877,8 @@ window runs, `s stop`; a project's `its chats` and `open folder`. **In the field `→` crosses to the rail instead** — columns win the arrow, and the rail always has rows in it because `projects` is pinned there — so a question in the field is answered with its digit, and a conversation's verbs are on their chords — -`ctrl+e`, `ctrl+o`, `ctrl+y`, `ctrl+t`. The foot names the one that matters on such a row: -`ctrl+o open folder`, or `ctrl+e pause` on a standing order. +`ctrl+e`, `ctrl+o`, `ctrl+y`, `ctrl+t`. The foot names the one that is true on every row, +`ctrl+o open folder`, and says the same sentence on all of them. ## The work on the right of home — what each task came to diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index 63f17afae6..f115cf222c 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -2068,9 +2068,13 @@ The line under the box is the foot, and **at rest it is exactly** still closes home from anywhere; the resting foot does not spend a cell naming it, and `alt+.` draws the whole map when you want it. -On a row with keys of its own the foot says what THAT row's keys do and gains the two that -are true everywhere — `enter opens the place this happened in · alt+. map · tab next place · -esc close` on a `since you left` line, a spend or `next up` row, or a fold door, and +On the grid every row under a moving heading — a conversation, a question, a running task, +a `next up` order, a `since you left` line — rests on the one sentence, +`type to search or start something new · ↑↓ pick · enter open · ctrl+o open folder · tab next place`, +and it does not change as the cursor walks. Elsewhere, on a row with keys of its own, the +foot says what THAT row's keys do and gains the two that are true everywhere — +`enter opens the place this happened in · alt+. map · tab next place · esc close` on a +`since you left` line or a `next up` row of the phone's list, on a spend row, or on a fold door, and `enter starts a new conversation and sends this · ↑ ask here · ↑↑ pick a match · alt+. map · tab next place · esc clear` on the action row — and `enter runs this command · ↑ ask here · ↑↑ pick a match · alt+. map · tab next place · esc clear` @@ -2852,7 +2856,7 @@ answer: | `ctrl+k` | **The switcher**: the card of every conversation this terminal has open. Inside a harness design's room, while its approval row is up, it saves the design instead — that row is modal and takes the key first | | `ctrl+r` | Bound. In the message box it is **spell it out** — see "Make my prompt better" above — in the `/files` list it opens the folder a file is in, and in the `/model` picker it fetches the newest model list. Nowhere else | | `ctrl+v` | **Bound**, on three surfaces: it moves how hard the thing you are standing on thinks — this conversation from the message box, a task, or a standing item on home. The machine's own default is the `thinking` row of `/settings` and is not on this chord. See "The thinking chip above the message box" and "ctrl+v — how hard the thing you are looking at thinks". Anywhere else it does nothing. It is **not** paste: most terminals spend `ctrl+v` (or `cmd+v`) on pasting before codeaf ever sees it, and a paste arrives as bracketed text rather than as this chord. Where your terminal does hand the chord over, it dials thinking | -| `ctrl+x` | Bound in three places: it drops a harness design from inside its room; on home it stops a standing item for good; and on a `running` row of home that this window holds it asks to stop that task (`ctrl+x stop it`, which the foot names where `→` crosses columns). Not bound anywhere else | +| `ctrl+x` | Bound in three places: it drops a harness design from inside its room; on home it stops a standing item for good; and on a `running` row of home that this window holds it asks to stop that task (`ctrl+x stop it` on the `alt+.` map; the foot under a field row is the resting sentence and does not name it). Not bound anywhere else | | `ctrl+y`, `ctrl+z` | Not bound | | `ctrl+<digit>` | **Bound as a second spelling of the place keys, on the terminals that report they can send it.** `ctrl` and a digit has no encoding in the scheme most terminals speak — which is why `alt+1` … `alt+7` (`⌥1` … `⌥7` on a Mac) are the first spelling and always will be — but a terminal running the kitty keyboard protocol sends it and says so, and where that report arrives `ctrl+1` … `ctrl+7` reach the same seven places. The map's line says `alt+1…7 or ctrl+1…7 go to a place` exactly when the alias is live. Where the terminal has said nothing, the chord does nothing and is never drawn | | `ctrl+.` | Two meanings, on two screens that cannot both be up. In a conversation it is every task this project has run (`/history`); while a place is standing it draws the key map, on the terminals that can send `ctrl+<digit>` | diff --git a/internal/tui3/home.go b/internal/tui3/home.go index 58067662f2..84824c1fd4 100644 --- a/internal/tui3/home.go +++ b/internal/tui3/home.go @@ -2655,12 +2655,21 @@ func (a *app) homeKey(msg tea.KeyPressMsg) tea.Cmd { h.say("folders on the other machine do not open here", "") return nil } - // A PROJECT ROW OPENS ITS FOLDER TOO, because on a two-column grid its - // `→` crosses to the right-hand column and this chord is the way to the - // strip's `open folder` there (homegrid.go's [app.homeCrossChord]). - if line, ok := h.previewLine(); ok && (line.kind == homeSession || line.kind == homeProjectRow) { + // EVERY ROW WITH A FOLDER OPENS IT — a conversation's workspace, a + // project's path, the workspace a standing order stands over, the + // conversation a `since you left` line happened in — because the foot + // names this chord on every row of the field and a key the foot names + // must work (homegrid.go's [app.homeCrossChord] and [homeRowFolder]). A + // row with no folder says nothing, which is the emptiness law on a key. + if line, ok := h.previewLine(); ok { path := homeRowFolder(line) - if path == "" || processOpener(path) != nil { + if path == "" { + if line.kind == homeSession || line.kind == homeProjectRow { + h.say("could not open "+path, "") + } + return nil + } + if processOpener(path) != nil { h.say("could not open "+path, "") return nil } @@ -5368,6 +5377,18 @@ func (a *app) homeHintWords() string { return "enter runs this command · ↑ ask here · ↑↑ pick a match · esc clear" } return "enter starts a new conversation and sends this · ↑ ask here · ↑↑ pick a match · esc clear" + case a.home.gridOn() && (line.kind == homeSession || line.kind == homeItem || line.kind == homeLedger): + // ONE SENTENCE ON EVERY ROW OF THE GRID. A conversation, a standing + // order, a landing, a line of news: each used to say its own thing here + // — `enter opens the place this happened in`, `enter open where it was + // asked · ctrl+e pause` — and the foot changed under the hand on every + // step of the cursor. The owner ruled (2026-09-15) that the rows under + // the moving headings all rest on the resting sentence, so the foot is + // something a person reads once and then stops reading; `enter open` is + // true of every one of them, and the chord the tail adds is the one true + // on all of them too ([app.homeCrossChord]). The rows' own sentences + // below still serve the phone and the filtered list, where there is no + // grid to be consistent across. case line.kind == homeQuiet && line.folded: return "enter or → show them · esc close" case line.kind == homeQuiet: diff --git a/internal/tui3/homegrid.go b/internal/tui3/homegrid.go index 82f2aadc5b..901c74cbee 100644 --- a/internal/tui3/homegrid.go +++ b/internal/tui3/homegrid.go @@ -1193,47 +1193,63 @@ func (a *app) homeGridCross(msg tea.KeyPressMsg) bool { return true } -// The chords the foot names for a row whose `→` crosses columns. Each is the -// strip's own word for the verb, after the key that reaches it without the strip. -const ( - homeFolderChordWord = "ctrl+o " + homeProjectFolderWord - homePauseChordWord = "ctrl+e " + homeItemPauseWord - homeStopChordWord = "ctrl+x " + stopActWord -) +// homeFolderChordWord is the ONE chord the foot names for a row whose `→` +// crosses columns: the strip's own word for the verb, after the key that +// reaches it without the strip. +const homeFolderChordWord = "ctrl+o " + homeProjectFolderWord -// homeCrossChord is the ONE verb chord the foot names on a grid row whose `→` -// crosses into the next column, and "" everywhere else. +// homeCrossChord is the one chord the foot names on a grid row whose `→` crosses +// into the next column, and "" everywhere else. // // COLUMNS WIN THE ARROW (DESIGN §6 ruling 6), so a row with a column of rows to // its right has verbs `→` cannot reach. The chords still reach them, and a door // a person cannot see is a door they never learn (docs/DESIGN-LANGUAGE.md: every -// chord keeps a visible door beside it) — so the foot says one, the one that -// ends nothing on the key: a watch's pause, the stop on a piece of work this -// window holds — which asks first and defaults to keep going, and is the one -// verb such a row has — and otherwise a conversation's or a project's folder. +// chord keeps a visible door beside it) — so the foot says one. +// +// AND IT IS THE SAME ONE ON EVERY ROW: THE FOLDER. It used to be the row's own +// verb — `ctrl+e pause` on a standing order, `ctrl+x stop` on work this window +// holds, the folder otherwise — so the foot changed as the cursor walked from a +// conversation to an order to a task, three sentences for one gesture (owner, +// 2026-09-15: "it flops between a few redundant alternatives"). One sentence on +// every row of the field is a thing a person stops reading, which is what a +// resting foot is for; pause and stop are still on their chords and on the +// `alt+.` map. The folder is the one verb every kind of row has, because every +// row of the field belongs to a project — a conversation's workspace, an +// order's, the conversation a landing ran in — and a row with none, or whose +// folder has been deleted, or on a machine whose folders are not this one's, +// says the four keys alone rather than a chord that would refuse. func (a *app) homeCrossChord(line homeLine) string { if !a.home.gridOn() || a.home.gridCrossTarget(1) < 0 { return "" } - switch { - case line.kind == homeItem: - return homePauseChordWord - case len(a.runningVerbs(line)) > 0: - return homeStopChordWord - case homeRowFolder(line) != "" && !a.hosted() && !a.home.gone[homeRowFolder(line)]: + if folder := homeRowFolder(line); folder != "" && !a.hosted() && !a.home.gone[folder] { return homeFolderChordWord } return "" } -// homeRowFolder is the folder `ctrl+o` opens for a row: a conversation's -// workspace, or a project's own path. +// homeRowFolder is the folder `ctrl+o` opens for a row, and "" for a row that +// belongs to no folder — a spend row, a fold. +// +// EVERY ROW OF THE FIELD ANSWERS, not only a conversation's: a standing order +// carries the workspace it stands over, and a `since you left` line carries the +// conversation the news happened in, so the foot's one chord is true on all of +// them ([app.homeCrossChord]). func homeRowFolder(line homeLine) string { switch line.kind { case homeSession: return strings.TrimSpace(line.row.Workspace) case homeProjectRow: return strings.TrimSpace(line.proj.Path) + case homeItem: + return strings.TrimSpace(line.item.Workspace) + case homeLedger: + if line.item.ID != "" { + return strings.TrimSpace(line.item.Workspace) + } + if line.cell != nil && line.cell.row != nil { + return strings.TrimSpace(line.cell.row.session.Workspace) + } } return "" } diff --git a/internal/tui3/homegrid_test.go b/internal/tui3/homegrid_test.go index c8217dc4b6..5b25ccccf8 100644 --- a/internal/tui3/homegrid_test.go +++ b/internal/tui3/homegrid_test.go @@ -2,11 +2,13 @@ package tui3 import ( "fmt" - "github.com/Agent-Field/codeaf/internal/tui2/tokens" "strings" "testing" "time" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/standing" + "github.com/Agent-Field/codeaf/internal/tui2/tokens" "github.com/charmbracelet/x/ansi" ) @@ -751,3 +753,62 @@ func TestALongTitleIsCutBeforeItsAge(t *testing.T) { t.Fatalf("the row is %d cells wide, its column holds %d", got, width-homeGridLead) } } + +// EVERY ROW OF THE FIELD RESTS ON ONE SENTENCE (owner, 2026-09-15). A +// conversation, a standing order on `next up`, a landing on `since you left`: +// the foot under each is the four keys and `ctrl+o open folder`, and the chord +// opens the folder that row belongs to — an order's workspace, the conversation +// a landing ran in. It used to be a different sentence on each kind of row. +func TestEveryFieldRowRestsOnTheOneFootAndItsChordOpensItsFolder(t *testing.T) { + var opened string + was := processOpener + processOpener = func(target string) error { opened = target; return nil } + t.Cleanup(func() { processOpener = was }) + + lab := newSwitchLab(t) + a := lab.open(180, 45) + dir := a.home.world.Projects[0].Dir + a.home.items = map[string][]StandingItemView{dir: {{Item: standing.Item{ID: "w1", Words: "water the plants", + Workspace: "/w/alpha", Status: standing.StatusActive, When: standing.When{Kind: standing.WhenAt}, + NextDue: lab.now.Add(2 * time.Hour)}}}} + a.home.build() + want := homeFootWord + rowSep + homeFolderChordWord + " · tab next place" + + homeLineOf(t, a, func(l homeLine) bool { return l.kind == homeSession && l.cell != nil && l.cell.panel == panelRecent }) + if hint := a.homeHint(); hint != want { + t.Fatalf("a conversation's foot is %q, want %q", hint, want) + } + homeLineOf(t, a, func(l homeLine) bool { return l.cell != nil && l.cell.panel == panelNext && l.stop() }) + if hint := a.homeHint(); hint != want { + t.Fatalf("a next up row's foot is %q, want %q", hint, want) + } + a.placeKeyPress(key("ctrl+o")) + if opened != "/w/alpha" { + t.Fatalf("ctrl+o on a standing order opened %q, want the workspace it stands over", opened) + } +} + +// AND A LANDING ON `since you left` IS ONE OF THEM: its chord opens the folder of +// the conversation that ran the work. +func TestASinceYouLeftRowRestsOnTheOneFootAndOpensItsConversationsFolder(t *testing.T) { + var opened string + was := processOpener + processOpener = func(target string) error { opened = target; return nil } + t.Cleanup(func() { processOpener = was }) + + l := newLiveLab(t) + l.task("-alpha", session.TaskIndexEntry{ID: "1", SessionID: "aaaa000000000002", Label: "spark fleet ssh audit", Title: "spark fleet ssh audit", + Status: string(session.TaskDone), Outcome: "all up", EndedAt: l.now.Add(-time.Hour)}) + a := l.open() + a.width, a.height = 180, 45 + a.home.seen = l.now.Add(-4 * time.Hour) + a.home.build() + homeLineOf(t, a, func(l homeLine) bool { return l.cell != nil && l.cell.panel == panelLeft && l.cell.kind == cellRow }) + if hint, want := a.homeHint(), homeFootWord+rowSep+homeFolderChordWord+" · tab next place"; hint != want { + t.Fatalf("a since you left row's foot is %q, want %q", hint, want) + } + a.placeKeyPress(key("ctrl+o")) + if opened == "" || !strings.HasSuffix(opened, "alpha") { + t.Fatalf("ctrl+o on a landing opened %q, want the folder of the conversation that ran it", opened) + } +} diff --git a/internal/tui3/homepanel_live_test.go b/internal/tui3/homepanel_live_test.go index cf991df11e..eaa4029c1c 100644 --- a/internal/tui3/homepanel_live_test.go +++ b/internal/tui3/homepanel_live_test.go @@ -463,14 +463,16 @@ func TestRunningOffersStopOnlyOnThisWindowsOwnTask(t *testing.T) { // ON A THREE-COLUMN HOME A `running` ROW IS IN THE FIELD — it has rows, and // that is what the field is (law 2, ruled 2026-09-15) — so `→` on it crosses to // the rail rather than opening the strip, and the stop the strip offers keeps a -// door (DESIGN §6 ruling 6). At 180×45 the foot on a task this window holds -// names `ctrl+x stop it`, in the tasks place's own spelling of the verb -// ([stopActWord]), and the chord raises the stop card. +// door: `ctrl+x` raises the stop card from the row. THE FOOT DOES NOT NAME IT. +// It used to say `ctrl+x stop it` on this row and something else on every +// other, and the owner ruled the same day that every row of the field rests on +// the one sentence — the four keys and `ctrl+o open folder`, which is as true +// of a task's conversation as of any other row ([app.homeCrossChord]). // // THE CROSSING SKIPS THE EMPTY MIDDLE. A field that fits in one column leaves // the next one white, and `→` reaches the rail over it rather than stopping on // air — a key that lands nowhere is the one state this surface may not be in. -func TestAThreeColumnRunningRowNamesItsStopOnTheFoot(t *testing.T) { +func TestAThreeColumnRunningRowRestsOnTheOneFootAndStillStops(t *testing.T) { l := newLiveLab(t) l.live("-alpha", "aaaa000000000001", session.SessionPresence{RunningTasks: []session.PresenceTask{ {ID: "5", Title: "mine", State: "running", StartedAt: l.now.Add(-time.Minute)}, @@ -488,9 +490,8 @@ func TestAThreeColumnRunningRowNamesItsStopOnTheFoot(t *testing.T) { if verbs := a.runningVerbs(a.home.lines[mine]); len(verbs) != 1 || verbs[0].word != stopActWord { t.Fatalf("the row's strip offers %+v, want the tasks place's `%s`", verbs, stopActWord) } - lines := strings.Split(homeText(a), "\n") - if foot := lines[len(lines)-1]; !strings.Contains(foot, "ctrl+x "+stopActWord) { - t.Fatalf("the foot on a running row this window holds is %q, want it to name `ctrl+x %s`", foot, stopActWord) + if foot := a.homeHint(); foot != homeFootWord+rowSep+homeFolderChordWord+" · tab next place" { + t.Fatalf("the foot on a running row this window holds is %q, want the resting sentence and the folder chord", foot) } a.placeKeyPress(key("right")) if a.strip.open || a.home.columnOf(a.home.cursor) != homeRailCol(a.home.cols) { From ab3721c5fe61d516be19cc5e7f3527d99d4cba11 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Tue, 15 Sep 2026 20:00:20 -0400 Subject: [PATCH 17/23] tui3: the right margin of every field row is a time, and the rest is description MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A landing read `3 files · 1d` beside a landing that read `10h`; a since-you-left task carried its cost where a made file carried its conversation's name; a conversation from another folder wore its project beside its age and one from this folder wore nothing; the own row said `here`; a background job carried its project inside its title. Six facts down one edge, and the owner asked what each of them was. The margin is a time now on every row — waited, landed, last spoke, running for, due in — and every other fact is the row's description in the middle column: the files open a landing's sentence, cost and conversation are a ledger row's description under the cursor, the project is the first clause of a conversation's, and `here` opens the own row's. The words that say what enter will do (another window, coming here, folder gone) keep the time's place. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- .../unreleased/1046-home-field-and-rail.md | 1 + docs/design/home-mission-control/DESIGN.md | 4 +- internal/manual/chat/home.md | 49 +++++++++-------- internal/tui3/homepanel_left.go | 25 +++++---- internal/tui3/homepanel_live_test.go | 27 ++++++---- internal/tui3/homepanel_needs.go | 35 ++++++------ internal/tui3/homepanel_recent.go | 54 +++++++++++++++---- internal/tui3/homepanel_running.go | 16 +++--- internal/tui3/homepanel_test.go | 21 +++++--- internal/tui3/switcher.go | 12 +++-- 10 files changed, 156 insertions(+), 88 deletions(-) diff --git a/docs/changes/unreleased/1046-home-field-and-rail.md b/docs/changes/unreleased/1046-home-field-and-rail.md index e231161b58..effaa3d958 100644 --- a/docs/changes/unreleased/1046-home-field-and-rail.md +++ b/docs/changes/unreleased/1046-home-field-and-rail.md @@ -15,6 +15,7 @@ invalidates: - "`enter` on a `needs you` watch with no conversation behind it did nothing and said nothing. It opens the item on the standing place, and the row's legend says `enter on standing` so the key is not a surprise. `enter` on a landing in another project opens it — the guard that refused a bucket other than this window's is gone." - "Home's `say what you want done` line drew no cursor at rest. It carries the same cursor a conversation's box does." - "The foot under a field row said that row's own thing: `ctrl+x stop it` on a running task this window holds, `enter open where it was asked · ctrl+e pause · esc close` on a standing order, `enter opens the place this happened in · esc close` on a `since you left` line, and the resting sentence with `ctrl+o open folder` on a conversation. No longer true on the grid: EVERY row under a moving heading — `needs you` and `unread`, `where you were`, `running`, `since you left`, `next up` — rests on the one sentence, `type to search or start something new · ↑↓ pick · enter open · ctrl+o open folder · tab next place`, and `ctrl+o` is true on all of them because every such row belongs to a folder — an order's workspace, the conversation a landing ran in. `ctrl+e` and `ctrl+x` still work and are on the `alt+.` map; the foot no longer names them. The phone's list and the filtered list keep the rows' own sentences." + - "The right margin of a field row carried whatever fact its panel had: a landing's `3 files · 1d` beside another landing's bare `10h`, a `since you left` task's cost (`$0.42`) beside a made file's conversation name (`Pricing Site`), a conversation's project word as a tag beside its age (`infra 3h`) on rows from other folders and nothing on rows from this one, `here` on this window's own row, and a background job's project inside its title. No longer true: THE RIGHT MARGIN OF EVERY ROW OF THE FIELD IS A TIME — how long a question has waited, how long ago a landing landed, when a conversation was last spoken in, how long a task or job has run, when an order is due. Everything that is not a time is the row's description, in the middle column: a landing's file count opens its sentence (`3 files · built the flow`), a task's cost and a file's conversation are their rows' descriptions under the cursor, a conversation's project is the first clause of its description, a job's project is its description, and `here` opens the own row's description. The one exception is the word that says what `enter` will do — `another window`, `coming here`, `folder gone` — which still stands in the time's place, because it is the truth about the key. On a frame with no description column the own row says no `here` at all; its bold says it." - "Home's resting foot was `type to search or start something new · ↑↓ pick · enter open · tab next place` on a quiet machine. It now carries the chord too — `… · enter open · ctrl+o open folder · tab next place`. The clause was always there on a machine whose right column had rows, because §6.6 refuses to leave a door invisible when `→` crosses columns rather than opening a row's verbs; what changed is that the rail always has rows now, so the condition is always met. The design's four keys still open the sentence and `esc` is still not on it." --- diff --git a/docs/design/home-mission-control/DESIGN.md b/docs/design/home-mission-control/DESIGN.md index 803737637e..630b4d7dff 100644 --- a/docs/design/home-mission-control/DESIGN.md +++ b/docs/design/home-mission-control/DESIGN.md @@ -31,8 +31,8 @@ own keys, each a door to the place that owns it. The chat list is one panel. the 2024 season only, or all three? enter spend today $0.14 of $20 where you were ▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪ under a cent per chat - › Understanding Hash Tables in Data … here ▁▂▁▃▅▂▁▁▇▃▂▅▂▁ 14 days $34.10 · opus 63% - explain open addressing vs chaining + › Understanding Hash Tables in Data … 2m ▁▂▁▃▅▂▁▁▇▃▂▅▂▁ 14 days $34.10 · opus 63% + here · explain open addressing vs chaining Understanding Bloom Filters in Eight … 11h AI Influencers and Developers in … 11h running · 2 Locate Recent Sandbox Task in … 1d ◐ Generate and Display First 200 Primes 4m diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index 94093465b3..db5cda05bd 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -18,7 +18,7 @@ answering one question you would ask walking up to a colleague's desk: the 2024 season only, or all three? enter spend today $0.14 of $20 ▁▂▁▃▅▂▁▁▇▃▂▅▂▁ 14 days $34.10 unread opus 63% · 3 chats and 1 task today - tier-B subs 3 files · 1d + tier-B subs 1d built the tier-B flow 1 accept 2 not right package release radar 1d 1 more · 15 older · tasks @@ -142,7 +142,7 @@ one word — no count after it, no clause at its right: ``` unread - tier-B subs 3 files · 1d + tier-B subs 1d package release radar 1d ``` @@ -150,15 +150,18 @@ The line is drawn only while the group has rows, and the rows are **newest first 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`. +**A landing is one line at rest**: its name and how long ago it landed. **The right margin +of every row of the field is a time** — how long a question has waited, how long ago a +landing landed, when you last spoke in a conversation, how long a task has run, when an +order is due — and nothing else stands there. -**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 row under the cursor grows a second line** — how many files it wrote, the first +sentence of what the work came to, and its two answers at the right. A landing with no files +to its name starts straight at the sentence, never `0 files`: ``` - tier-B subs 3 files · 1d - built the tier-B subscription flow 1 accept 2 not right + tier-B subs 1d + 3 files · 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, @@ -335,9 +338,11 @@ Four kinds of line, newest first, four of them — eight in a tall window — th `N more · tasks`: - **a task that landed** — its name and what it came to, the first sentence of its report, - with its cost at the right when it cost anything. A task that stopped without finishing - says why instead: `lost the connection`, `went in circles`, `out of steps`; -- **a file a conversation made** — `made <name>`, with that conversation's name at the right; + with how long ago it landed at the right and its cost under the cursor when it cost + anything. A task that stopped without finishing says why instead: `lost the connection`, + `went in circles`, `out of steps`; +- **a file a conversation made** — `made <name>`, with how long ago at the right and that + conversation's name under the cursor; - **a standing item that fired** — its own last-look line, in its own words; a one-off that fired and stood down reads `fired 3 minutes ago — it told you`; - **what memory learned or let go** — `learned 2 things, let go of 1`. @@ -354,22 +359,24 @@ nothing. **The `where you were` panel is your conversations**, under `needs you`: -- **this window's own conversation first**, in bold, with `here` at the right and **the last - thing you said in it** on the line under it — your own words, never a note the session - left for itself. A conversation this launch has only just started is one line, - `new conversation` and `here`, until you say something in it; +- **this window's own conversation first**, in bold, with when you last spoke in it at the + right and **`here` and the last thing you said in it** in its description — your own + words, never a note the session left for itself. A conversation this launch has only just + started is one bold line, `new conversation`, until you say something in it. On a frame + too narrow for the description column the bold alone says it is this window's; - then **the most recently spoken-in of the rest**, each with its age at the right. A - conversation in another project wears that project's name beside the age; one in this - window's folder wears none, and neither does one in your home directory or in a scratch - folder at the top of `/tmp`; + conversation in another project carries that project's name in its description, under + the cursor, before the last thing you said; one in this window's folder carries none, and + neither does one in your home directory or in a scratch folder at the top of `/tmp`; - then `N more · type to find one` — typing finds any conversation on the machine. A conversation waiting on you is on `needs you` and not here too. One that is mid-reply or on its way here stays. Five rows show, ten in a tall window. -**The margin says what `enter` will do**: the age for an ordinary row, `another window` for -one a terminal is sitting in, `coming here` while one moves over, `folder gone` for one whose -folder was deleted. An `ask here` errand you started a minute ago sits at the very top. +**The margin is a time, unless it has to say what `enter` will do**: the age for an +ordinary row, and in its place `another window` for one a terminal is sitting in, `coming +here` while one moves over, `folder gone` for one whose folder was deleted — the one kind +of word allowed to stand where a time does, because it is the truth about the key. An `ask here` errand you started a minute ago sits at the very top. **On `space` `space` the cursor is already on the chat you were in before this one**, so a switch back is two keys — `space` `space`, then `enter` — and `esc` still goes back to the diff --git a/internal/tui3/homepanel_left.go b/internal/tui3/homepanel_left.go index e14f061f2d..76307fa2e5 100644 --- a/internal/tui3/homepanel_left.go +++ b/internal/tui3/homepanel_left.go @@ -35,7 +35,7 @@ func (leftPanel) rows(in *homeGridInput) homePanelRows { if row.task != nil && checking[taskLedgerKey(*row.task)] { continue } - lines = append(lines, leftLine(row)) + lines = append(lines, leftLine(row, in.now)) } out := homePanelCut(panelLeft, lines) if len(lines) > 0 { @@ -47,8 +47,15 @@ func (leftPanel) rows(in *homeGridInput) homePanelRows { // leftLine is one ledger row as a door of home's column. The place word rides // [homeLine.project] as it always has; the row itself rides the cell, because a // task's and a file's doors are the row's own and not a place's. -func leftLine(row switcherRow) homeLine { - cell := &homeCell{panel: panelLeft, title: row.title, right: row.margin, row: &row} +// +// THE MARGIN IS WHEN IT HAPPENED. It used to be a task's cost on one row and a +// conversation's name on the next — two unrelated facts down one edge of one +// panel — and both are the row's description now, drawn under the cursor +// ([switcherRow.note]); the edge reads `3h`, `1h`, like every row of the field +// (owner, 2026-09-15). +func leftLine(row switcherRow, now time.Time) homeLine { + cell := &homeCell{panel: panelLeft, title: row.title, right: sinceAt(row.at, now), row: &row, + sub: strings.TrimSpace(row.note), grows: strings.TrimSpace(row.note) != ""} return homeLine{kind: homeLedger, project: row.place, dir: leftKey(row), view: row.item, item: row.item.Item, cell: cell} } @@ -88,7 +95,7 @@ const ( ) // ledgerLanded is a line per piece of work that landed since the look stamp: -// `<label> · <outcome>`, with the cost at the right when there was one. +// `<label> · <outcome>`, with the cost as its description when there was one. // // ONE LINE PER PIECE OF WORK. [session.LandedSince] hands back a task's parts // and an adaptive run's workers too, and a person away for a night asked for @@ -100,12 +107,12 @@ func ledgerLanded(world session.World, seen time.Time) []switcherRow { continue } entry := landed.Entry - margin := "" + note := "" if entry.Cost > 0 { - margin = dollars(entry.Cost) + note = dollars(entry.Cost) } out = append(out, switcherRow{kind: switcherLedger, session: landed.Session, title: ledgerTaskLine(entry, landed.Session), - place: pageTasks.word(), at: entry.EndedAt, task: &entry, margin: margin}) + place: pageTasks.word(), at: entry.EndedAt, task: &entry, note: note}) } return out } @@ -138,13 +145,13 @@ func ledgerTaskLine(entry session.TaskIndexEntry, row session.SessionRow) string } // ledgerMade is a line per file a conversation made since the look stamp: -// `made <name>`, with the conversation's title at the right. +// `made <name>`, with the conversation's title as its description. func ledgerMade(world session.World, made []session.Artifact) []switcherRow { out := make([]switcherRow, 0, len(made)) for _, artifact := range made { out = append(out, switcherRow{kind: switcherLedger, title: ledgerMadeWord + filepath.Base(artifact.Path), place: ledgerMadePlace, at: artifact.Created, path: artifact.Path, - margin: ledgerMadeIn(world, artifact.Session)}) + note: ledgerMadeIn(world, artifact.Session)}) } return out } diff --git a/internal/tui3/homepanel_live_test.go b/internal/tui3/homepanel_live_test.go index eaa4029c1c..8baeff193a 100644 --- a/internal/tui3/homepanel_live_test.go +++ b/internal/tui3/homepanel_live_test.go @@ -132,8 +132,11 @@ func TestNeedsYouCarriesATaskWaitingOnYourCall(t *testing.T) { FilesChanged: 3}) a := l.open() rows := panelRows(a, panelNeeds) - 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) + // THE MARGIN IS WHEN IT LANDED AND NOTHING ELSE; the files it wrote open its + // description (owner, 2026-09-15: the right margin of every field row is a + // time). It used to read `3 files · 30m`. + if len(rows) != 1 || rows[0].title != "fix the flaky sieve" || rows[0].right != "30m" || !strings.HasPrefix(rows[0].sub, "3 files · ") { + t.Fatalf("the task's call is not a one-line row of needs you with its files in its description: %+v", rows) } if rows[0].mark != cellMarkNone { t.Fatalf("a landing wears a mark: %+v", rows[0]) @@ -337,8 +340,8 @@ func consentQuestionAt(id uint64, text string, asked time.Time) session.Presence // ── where you were ────────────────────────────────────────────────────────── -// A BRAND-NEW LAUNCH'S OWN ROW IS ONE LINE: `new conversation` and `here`, no -// age and nothing under it — whatever the journal's tail has on hand — until +// A BRAND-NEW LAUNCH'S OWN ROW IS ONE LINE: `new conversation` in bold, no age +// and nothing under it — whatever the journal's tail has on hand — until // its person says something, and then the line under it is what they said. func TestAFreshLaunchsHereRowIsOneLineUntilItsFirstMessage(t *testing.T) { l := newLiveLab(t) @@ -364,8 +367,8 @@ func TestAFreshLaunchsHereRowIsOneLineUntilItsFirstMessage(t *testing.T) { } a := openOn() own := panelRows(a, panelRecent)[0] - if own.title != unnamedConversationWord || own.right != homeHereWord || own.sub != "" { - t.Fatalf("the fresh launch's row is not one line saying here: %+v", own) + if own.title != unnamedConversationWord || own.right != "" || own.sub != "" || !own.bold { + t.Fatalf("the fresh launch's row is not one bold line with nothing at its right: %+v", own) } if next := homeLineAfter(homeText(a), unnamedConversationWord); !strings.Contains(next, "Porting the Resume Picker") { t.Fatalf("the fresh launch's row carries a line under it:\n%s", homeText(a)) @@ -404,7 +407,7 @@ func TestRunningDrawsEachTaskAndJobWithWhatItIsDoing(t *testing.T) { } if !strings.HasSuffix(job.title, rowSep+runningJobWord) || !strings.HasPrefix(job.title, "npm run dev · ") || job.right != "up 3h" || job.mark != cellMarkNone { - t.Fatalf("the job row is not `<title> · <project> · a background job` up its age: %+v", job) + t.Fatalf("the job row is not `<title> · a background job` up its age: %+v", job) } if frame := homeText(a); !strings.Contains(frame, "running · 2") { t.Fatalf("the heading does not count the work:\n%s", frame) @@ -590,8 +593,14 @@ func TestSinceYouLeftNamesEachLandedTaskAndFile(t *testing.T) { t.Fatalf("line %d is %q, want %q", i, rows[i].title, title) } } - if rows[0].right != "Pricing Site" || rows[2].right != "$0.42" || rows[1].right != "" { - t.Fatalf("the right margins are not the conversation and the cost: %+v", rows) + // THE MARGIN IS WHEN EACH HAPPENED, and the conversation a file was made in + // and what a task cost are the rows' descriptions (owner, 2026-09-15). They + // used to be the margins — a name on one row, money on the next. + if rows[0].right != "1h" || rows[1].right != "2h" || rows[2].right != "3h" { + t.Fatalf("the right margins are not when each happened: %+v", rows) + } + if rows[0].sub != "Pricing Site" || rows[2].sub != "$0.42" || rows[1].sub != "" || !rows[0].grows || !rows[2].grows { + t.Fatalf("the descriptions are not the conversation and the cost, under the cursor: %+v", rows) } if frame := homeText(a); !strings.Contains(frame, "since you left · 12h") { t.Fatalf("the heading does not say how long you were away:\n%s", frame) diff --git a/internal/tui3/homepanel_needs.go b/internal/tui3/homepanel_needs.go index a4ae670e4c..11377e3f77 100644 --- a/internal/tui3/homepanel_needs.go +++ b/internal/tui3/homepanel_needs.go @@ -321,8 +321,9 @@ func needsCallAt(entry session.TaskIndexEntry) time.Time { 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. +// needsCall is one of those rows: the title, how long ago it landed, and — +// under the cursor — how many files it wrote, 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 == "" { @@ -333,9 +334,9 @@ func needsCall(project session.Project, row session.SessionRow, entry session.Ta // 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), + cell := &homeCell{panel: panelNeeds, title: title, right: sinceAt(asked, now), key: needsCallKey + entry.ID, - grows: true, sub: needsCallSub(entry, status), answers: needsCallAnswers(status)} + grows: true, sub: rowClauses(needsCallFiles(entry), 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} @@ -355,24 +356,20 @@ func needsCallSub(entry session.TaskIndexEntry, status session.TaskStatus) strin 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`. +// needsCallFiles is how many files a landing wrote, for the head of its +// description — `3 files · <what it came to>` — and "" for one that wrote none, +// never `0 files` (the emptiness law). // -// 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) +// IT USED TO BE THE RIGHT MARGIN, as one clause with the age (`3 files · 1d`), +// so a landing that wrote nothing read `10h` beside one that read `1 file · 1d` +// — two shapes in one column for one kind of row. THE RIGHT MARGIN OF EVERY ROW +// OF THE FIELD IS A TIME (owner, 2026-09-15), and everything that is not a time +// is in the description column ([homeDescLines]). +func needsCallFiles(entry session.TaskIndexEntry) string { if entry.FilesChanged <= 0 { - return age - } - files := itoa(entry.FilesChanged) + plural(" file", entry.FilesChanged) - if age == "" { - return files + return "" } - return files + rowSep + age + return itoa(entry.FilesChanged) + plural(" file", entry.FilesChanged) } // The two keys a landing is answered with ON HOME, and the landing keys they diff --git a/internal/tui3/homepanel_recent.go b/internal/tui3/homepanel_recent.go index 60db281c92..306917320a 100644 --- a/internal/tui3/homepanel_recent.go +++ b/internal/tui3/homepanel_recent.go @@ -64,18 +64,38 @@ func (recentPanel) rows(in *homeGridInput) homePanelRows { // row's own time ([session.SessionRow.At] is the person's last word, and zero on // a shell nobody has spoken in), so no tail read off the journal can put a line // under it first. +// +// `here` IS THE FIRST WORD OF ITS DESCRIPTION, not its margin. THE RIGHT MARGIN +// OF EVERY ROW OF THE FIELD IS A TIME (owner, 2026-09-15): this row's is when +// its person last spoke in it, like every row under it, and the word that says +// it is this window's own stands where the row's other facts do — permanently, +// because it is true whether or not the cursor is here. On a frame with no +// description column the word is not said at all: it would cost the column a +// row on the frames that have the fewest, and the bold already says it. func recentOwnCell(row switcherRow, in *homeGridInput) *homeCell { said := "" if !row.session.At.IsZero() { said = switcherFirstLine(in.last[row.session.Transcript].LastUser) } - return &homeCell{panel: panelRecent, title: row.title, right: switcherMarginWord(row), hold: true, bold: true, sub: said} + if in.desc { + said = rowClauses(homeHereWord, said) + } + return &homeCell{panel: panelRecent, title: row.title, right: row.age, bold: true, sub: said} } -// recentCell is any other row: its age, or the one fact that decides what enter -// will do, at the margin — and its project's name beside that only when it is -// not this window's own folder, where the tag would be the same word on every -// row, and only when the folder has a name ([chatProjectTag]). +// recentCell is any other row: its age at the margin — or the one fact that +// decides what enter will do, `another window`, `folder gone`, which is the one +// thing allowed to stand in a time's place because it is the truth about the +// key — and, under the cursor, its project's name and the last thing its person +// said in it. +// +// THE PROJECT IS A DESCRIPTION AND NOT A TAG. It used to stand beside the age +// (`infra 3h`) on a row from another folder and nowhere on a row from this +// window's own, so the margin was one word wide on some rows and two on others +// (owner, 2026-09-15: "what are these and why are they absent elsewhere?"). It +// is the first clause of the description now, still only where it says +// something — a row of this window's own folder, a scratch folder or the home +// directory has no project word ([chatProjectTag]). func recentCell(row switcherRow, in *homeGridInput) *homeCell { // THE LAST THING THIS PERSON SAID IN IT, under the cursor — which is the one // fact that tells two conversations with similar names apart, and the same @@ -84,19 +104,35 @@ func recentCell(row switcherRow, in *homeGridInput) *homeCell { // the row being read, off the draw), and a row with nothing to say draws // nothing rather than a gap. cell := &homeCell{panel: panelRecent, title: row.title, right: switcherMarginWord(row)} + project := "" + if homeBucketOf(row.session.Transcript) != in.bucket { + project = chatProjectTag(row, in.tilde) + } + said := "" if in.desc { - cell.grows, cell.sub = true, switcherFirstLine(in.last[row.session.Transcript].LastUser) + said = switcherFirstLine(in.last[row.session.Transcript].LastUser) } + cell.sub = rowClauses(project, said) + cell.grows = cell.sub != "" cell.hold = cell.right != row.age if row.door && cell.right == homeHeldShort { cell.door = takeoverHeldDoorWord } - if homeBucketOf(row.session.Transcript) != in.bucket { - cell.tag = chatProjectTag(row, in.tilde) - } return cell } +// rowClauses joins the clauses of one row's description that are not empty, so +// a row with a project and nothing said yet reads `infra` and not `infra · `. +func rowClauses(clauses ...string) string { + var kept []string + for _, clause := range clauses { + if clause = strings.TrimSpace(clause); clause != "" { + kept = append(kept, clause) + } + } + return strings.Join(kept, rowSep) +} + // chatProjectTag is the project word a chat row wears, and NOTHING FOR A // FOLDER THAT IS NOT A PROJECT: the home directory, whose word is a lone `~`, // and a scratch folder at the top of the temporary directory, whose word is a diff --git a/internal/tui3/homepanel_running.go b/internal/tui3/homepanel_running.go index 5886facfed..609923d96a 100644 --- a/internal/tui3/homepanel_running.go +++ b/internal/tui3/homepanel_running.go @@ -140,19 +140,19 @@ func runningDoing(task session.PresenceTask) string { return doing } -// runningJobLine is one background job: one line, because a dev server has no -// activity to report — only where it is ([chatProjectTag]'s word for its -// folder) and how long it has been up. +// runningJobLine is one background job: `<command> · a background job`, how +// long it has been up at the margin, and — under the cursor — where it is +// ([chatProjectTag]'s word for its folder), because a dev server has no +// activity to report. The project used to ride inside the title; it is a fact +// like every other row's project and stands where theirs do (owner, +// 2026-09-15: the right margin is a time, the rest is description). func runningJobLine(row switcherRow, job session.PresenceJob, project string, now time.Time) homeLine { - title := strings.TrimSpace(job.Title) - if project != "" { - title += rowSep + project - } clock := "" if age := sinceAt(job.StartedAt, now); age != "" { clock = runningUpWord + age } - cell := &homeCell{panel: panelRunning, title: title + rowSep + runningJobWord, key: runningJobKey + job.ID} + cell := &homeCell{panel: panelRunning, title: strings.TrimSpace(job.Title) + rowSep + runningJobWord, + key: runningJobKey + job.ID, sub: project, grows: project != ""} homeLiveMargin(cell, row, clock) return switcherRowLine(row, cell) } diff --git a/internal/tui3/homepanel_test.go b/internal/tui3/homepanel_test.go index d46a79b11e..f739209ac9 100644 --- a/internal/tui3/homepanel_test.go +++ b/internal/tui3/homepanel_test.go @@ -103,8 +103,11 @@ func TestSinceYouLeftIsItsOwnPanelOfDoors(t *testing.T) { } } -// WHERE YOU WERE: this window's own conversation first with `here`, the last -// thing said in it under it, then the most recent quiet ones. +// WHERE YOU WERE: this window's own conversation first, in bold, the last thing +// said in it under it, then the most recent quiet ones. A row from another +// folder carries its project as its description, under the cursor — the margin +// is a time on every row (owner, 2026-09-15; the project used to be a tag +// beside the age, and `here` the own row's margin). func TestWhereYouWereLeadsWithThisWindowsOwnConversation(t *testing.T) { lab := newSwitchLab(t) a := lab.open(120, 45) @@ -117,14 +120,20 @@ func TestWhereYouWereLeadsWithThisWindowsOwnConversation(t *testing.T) { t.Fatalf("this window's own conversation is not the first row of where you were:\n%s", frame) } lines := strings.Split(frame, "\n") - if !strings.Contains(lines[own], homeHereWord) || !strings.Contains(lines[own+1], "explain open addressing") { - t.Fatalf("the own row does not say here with its last words under it:\n%s", frame) + // (The rail beside it may say `here` in a whisper of its own, so the row is + // asked for its words rather than the screen line searched for the word.) + if mine := panelRows(a, panelRecent)[0]; mine.right == homeHereWord || !mine.bold || !strings.Contains(lines[own+1], "explain open addressing") { + t.Fatalf("the own row does not carry its last words under it, with nothing but a time at its right:\n%s", frame) } // AND A ROW FROM ANOTHER FOLDER SAYS WHICH, where one from this folder does // not. The conversation mid-turn is here too: `running` lists the work a // conversation sent out and never the conversation, so this is its panel. - if moving := lines[own+2]; !strings.Contains(moving, "Bounty Reward Companies") || !strings.Contains(moving, "beta") { - t.Fatalf("a row from another folder does not carry its project:\n%s", frame) + if moving := lines[own+2]; !strings.Contains(moving, "Bounty Reward Companies") || strings.Contains(moving, "beta") { + t.Fatalf("a row from another folder wears its project on its own line:\n%s", frame) + } + homeLineOf(t, a, func(l homeLine) bool { return l.cell != nil && l.cell.title == "Bounty Reward Companies" }) + if bounty := a.home.lines[a.home.cursor]; bounty.cell.sub != "beta" || !bounty.cell.grows { + t.Fatalf("a row from another folder does not carry its project as its description: %+v", bounty.cell) } if quiet := lines[own+3]; !strings.Contains(quiet, "Quiet Chat a") { t.Fatalf("the quiet rows do not follow in recency order:\n%s", frame) diff --git a/internal/tui3/switcher.go b/internal/tui3/switcher.go index a24845f804..054d504602 100644 --- a/internal/tui3/switcher.go +++ b/internal/tui3/switcher.go @@ -93,11 +93,13 @@ type switcherRow struct { options []session.AnswerOption // task is the landed piece of work a `since you left` line names, and path // the file one names; each is the line's door (place_home.go's - // [app.homeLedgerEnter]). margin is what such a line carries at its right — - // a task's cost, the conversation a file was made in. - task *session.TaskIndexEntry - path string - margin string + // [app.homeLedgerEnter]). note is the one fact such a line carries besides + // its title — a task's cost, the conversation a file was made in — and it is + // the line's DESCRIPTION, drawn under the cursor, and not its margin: the + // margin is when it happened, like every row of the field (owner, + // 2026-09-15). + task *session.TaskIndexEntry + path string } // switcherReading is the whole of what the grid's panels read off the machine: From 39b785da8b4340d275e4b9164667df737f7dea89 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Tue, 15 Sep 2026 20:59:45 -0400 Subject: [PATCH 18/23] =?UTF-8?q?tui3:=20a=20home=20panel's=20fold=20is=20?= =?UTF-8?q?a=20toggle=20=E2=80=94=20N=20more=20opens=20the=20panel,=20N=20?= =?UTF-8?q?fewer=20shuts=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every panel folded its tail into a door: `N more · tasks` opened the tasks place, `needs you` said `N more · M older · tasks` for the landings that aged off it, and `where you were`'s `N more · type to find one` could not be stood on at all because typing was its door. The owner found one line that opened somewhere else and another that was not a stop, and asked for one thing that expands. The fold is one line now, `N more`, a stop on every panel, and enter or a click opens the panel: its cap is lifted, the other panels squeeze to their floors in law 5's order — the open one is taken from last — and it takes the column; the line reads `N fewer` and enter shuts it. One panel is open at a time. needs you's aged landings are counted in its fold and come back when it opens. An open panel taller than the column names the place for what it still cannot show, the one case a fold still names one. It wears no mark, because the grid has two and no other (law 8). DESIGN.md law 9 is amended. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- .../unreleased/1046-home-field-and-rail.md | 1 + docs/design/home-mission-control/DESIGN.md | 16 +- internal/manual/chat/home.md | 76 +++++---- internal/manual/chat/keys.md | 4 +- internal/manual/chat_test.go | 3 +- internal/tui3/home.go | 47 +++++- internal/tui3/homegrid.go | 147 +++++++++++++----- internal/tui3/homegrid_test.go | 107 ++++++++++++- internal/tui3/homeneedsgroups_test.go | 4 +- internal/tui3/homepanel_left.go | 2 +- internal/tui3/homepanel_live_test.go | 18 ++- internal/tui3/homepanel_needs.go | 2 +- internal/tui3/homepanel_next.go | 2 +- internal/tui3/homepanel_projects.go | 2 +- internal/tui3/homepanel_recent.go | 2 +- internal/tui3/homepanel_running.go | 2 +- internal/tui3/homepanel_test.go | 2 +- internal/tui3/placeeverymouse_test.go | 23 +-- 18 files changed, 355 insertions(+), 105 deletions(-) diff --git a/docs/changes/unreleased/1046-home-field-and-rail.md b/docs/changes/unreleased/1046-home-field-and-rail.md index effaa3d958..2fc13e17a2 100644 --- a/docs/changes/unreleased/1046-home-field-and-rail.md +++ b/docs/changes/unreleased/1046-home-field-and-rail.md @@ -16,6 +16,7 @@ invalidates: - "Home's `say what you want done` line drew no cursor at rest. It carries the same cursor a conversation's box does." - "The foot under a field row said that row's own thing: `ctrl+x stop it` on a running task this window holds, `enter open where it was asked · ctrl+e pause · esc close` on a standing order, `enter opens the place this happened in · esc close` on a `since you left` line, and the resting sentence with `ctrl+o open folder` on a conversation. No longer true on the grid: EVERY row under a moving heading — `needs you` and `unread`, `where you were`, `running`, `since you left`, `next up` — rests on the one sentence, `type to search or start something new · ↑↓ pick · enter open · ctrl+o open folder · tab next place`, and `ctrl+o` is true on all of them because every such row belongs to a folder — an order's workspace, the conversation a landing ran in. `ctrl+e` and `ctrl+x` still work and are on the `alt+.` map; the foot no longer names them. The phone's list and the filtered list keep the rows' own sentences." - "The right margin of a field row carried whatever fact its panel had: a landing's `3 files · 1d` beside another landing's bare `10h`, a `since you left` task's cost (`$0.42`) beside a made file's conversation name (`Pricing Site`), a conversation's project word as a tag beside its age (`infra 3h`) on rows from other folders and nothing on rows from this one, `here` on this window's own row, and a background job's project inside its title. No longer true: THE RIGHT MARGIN OF EVERY ROW OF THE FIELD IS A TIME — how long a question has waited, how long ago a landing landed, when a conversation was last spoken in, how long a task or job has run, when an order is due. Everything that is not a time is the row's description, in the middle column: a landing's file count opens its sentence (`3 files · built the flow`), a task's cost and a file's conversation are their rows' descriptions under the cursor, a conversation's project is the first clause of its description, a job's project is its description, and `here` opens the own row's description. The one exception is the word that says what `enter` will do — `another window`, `coming here`, `folder gone` — which still stands in the time's place, because it is the truth about the key. On a frame with no description column the own row says no `here` at all; its bold says it." + - "Every home panel folded its tail into a door: `N more · tasks` opened the tasks place, `N more · standing` opened standing, `needs you` said `N more · M older · tasks` for the landings that aged off it, and `where you were`'s `N more · type to find one` was not a stop at all because typing was its door. No longer true: EVERY FOLD IS ONE LINE, `N more`, AND IT IS A TOGGLE. `enter` (or a click) on it opens the panel — its cap is lifted, the other panels squeeze to their floors in law 5's order, and it takes the column — and the line reads `N fewer`; enter again folds it. One panel is open at a time. `needs you`'s aged landings are counted in its `N more` and come back when it is opened. An open panel taller than the column shows what fits and its fold names the place for the rest — `3 fewer · 40 more · tasks` — the one case a fold still names a place. The fold wears no mark (law 8). The foot on a fold says `enter shows the rest` / `enter folds them`. DESIGN.md law 9 is amended and says what it used to say." - "Home's resting foot was `type to search or start something new · ↑↓ pick · enter open · tab next place` on a quiet machine. It now carries the chord too — `… · enter open · ctrl+o open folder · tab next place`. The clause was always there on a machine whose right column had rows, because §6.6 refuses to leave a door invisible when `→` crosses columns rather than opening a row's verbs; what changed is that the rail always has rows now, so the condition is always met. The design's four keys still open the sentence and `esc` is still not on it." --- diff --git a/docs/design/home-mission-control/DESIGN.md b/docs/design/home-mission-control/DESIGN.md index 630b4d7dff..8d13543911 100644 --- a/docs/design/home-mission-control/DESIGN.md +++ b/docs/design/home-mission-control/DESIGN.md @@ -36,7 +36,7 @@ own keys, each a door to the place that owns it. The chat list is one panel. Understanding Bloom Filters in Eight … 11h AI Influencers and Developers in … 11h running · 2 Locate Recent Sandbox Task in … 1d ◐ Generate and Display First 200 Primes 4m - 70 more · type to find one working · checking + 70 more working · checking npm run dev · pricing-site up 3h since you left · 12h Spark Fleet Ssh Audit · 2 hosts up, 1 not next up @@ -98,7 +98,7 @@ is the reading that produced the 2026-09-15 amendment. whispers are in §4 and are the only prose about the product on the screen. 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>`. + were` shrink last. A squeezed panel keeps its heading and its `N more` fold. A GROUP INSIDE A PANEL folds before the panel gives up a row above it: the rows of `needs you`'s `unread` 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 @@ -124,8 +124,16 @@ is the reading that produced the 2026-09-15 amendment. `unread`, 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 - `N more` and the name of its place; the fold is the door. A tall frame hands +9. **Nothing grows past its budget — unless you open it.** Every panel folds + inside itself with `N more`, and THE FOLD IS A TOGGLE (owner, 2026-09-15; it + used to read `N more · <place>` and be a door into the place, and `where you + were`'s `N more · type to find one` was not a stop at all). `enter` on it + opens the panel: its cap is lifted, the other panels squeeze in law 5's order + to their floors, and it takes the column; the fold reads `N fewer` and enter + shuts it. One panel is open at a time. An open panel taller than the column + shows what fits and its fold names the place for the rest — `3 fewer · 40 + more · tasks` — so nothing is left without a door. The fold wears no mark + (law 8). A tall frame hands its spare rows out — only once every panel has its natural height — in the squeeze's order reversed, one row each round: `needs you` and `running` to 8, `where you were` from 5 to 10, `since you left` to 8, `projects` to 8, `next diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index db5cda05bd..daf07d662e 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -21,13 +21,13 @@ answering one question you would ask walking up to a colleague's desk: tier-B subs 1d built the tier-B flow 1 accept 2 not right package release radar 1d - 1 more · 15 older · tasks + 16 more where you were Understanding Hash Tables here explain open addressing vs chaining Understanding Bloom Filters 11h - 70 more · type to find one + 70 more running · 2 ⠋ Generate and Display First 200 Primes 4m @@ -131,7 +131,7 @@ is that sentence, whole. A question with a paragraph shows only its first line. **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 `?` +Four rows show, eight in a tall window, then `N more`. Amber is spent on the `?` mark and on nothing else; a machine with nothing waiting has no accent on it at all. ## unread — the group of landings nobody has checked, work that finished and wants a look @@ -176,7 +176,7 @@ and on its record. `enter` opens the conversation on the task's record. 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 +panel's last line counts it with the rest it hides — `3 more` — and `enter` there opens the 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. @@ -240,7 +240,8 @@ A conversation another terminal is holding is not refused: `enter` brings it her Work that landed as `your call` **waits until you decide about it**, however many days that is: nothing more happens to that work until somebody answers it. Only its row on home ages — two days after it landed it leaves `needs you` for the count on the panel's last line, -`3 older · tasks`, and the tasks place still lists it. +`3 more`, and the tasks place still lists it; `enter` on that line opens the panel and shows +it again. Three ways to settle it, and they are the same door: @@ -289,7 +290,7 @@ has been `up`. A watch mid-pass is its own words, what the pass is doing and how long ago it began. A row another terminal holds says `another window`, and `enter` brings its conversation here. -Four rows show, eight in a tall window, then `N more · tasks`. There is no separate "moving" +Four rows show, eight in a tall window, then `N more`. There is no separate "moving" column or `◐` mark any more; this panel is that list. **`s` stops only what this window is running.** `→` on a task this window's own @@ -335,7 +336,7 @@ away: ``` Four kinds of line, newest first, four of them — eight in a tall window — then -`N more · tasks`: +`N more`: - **a task that landed** — its name and what it came to, the first sentence of its report, with how long ago it landed at the right and its cost under the cursor when it cost @@ -355,7 +356,7 @@ the panel keeps its heading and `what watches and tasks did while the terminal w The memory line is not wired yet: its figures read zero on this surface, and a zero draws nothing. -## Where you were — this window's chat, the one before it, and N more · type to find one +## Where you were — this window's chat, the one before it, and N more **The `where you were` panel is your conversations**, under `needs you`: @@ -368,7 +369,8 @@ nothing. conversation in another project carries that project's name in its description, under the cursor, before the last thing you said; one in this window's folder carries none, and neither does one in your home directory or in a scratch folder at the top of `/tmp`; -- then `N more · type to find one` — typing finds any conversation on the machine. +- then `N more` — `enter` on it opens the panel and shows them; typing finds any + conversation on the machine whether or not it is drawn. A conversation waiting on you is on `needs you` and not here too. One that is mid-reply or on its way here stays. Five rows show, ten in a tall window. @@ -404,8 +406,8 @@ folder, so its own name and the counts stay on the row. **`→` on a project row offers `its chats`** — which types the project's name into the box, so the search lists every conversation in it — **and `open folder`**. Five rows show, eight -in a tall window, then `N more · type to find one`; typing a folder's name or path finds the -rest, and a typed path starts a conversation there too (*Start something new from home*). +in a tall window, then `N more`, which `enter` opens; typing a folder's name or path finds +the rest, and a typed path starts a conversation there too (*Start something new from home*). `ctrl+t` on a conversation's row still starts one in that row's folder, but the projects panel is the way home offers it now. @@ -430,9 +432,8 @@ Where to go for the whole figure instead: panel, and conversations are rows of `where you were`, each wearing its project's name when it is not this window's. -What is folded is **the tail of each panel** — `N more · tasks`, `N more · standing`, -`N more · type to find one` — and a fold line that names a place is a door: `enter` on it -opens that place. +What is folded is **the tail of each panel** — one `N more` line under it — and the fold +is a toggle: `enter` on it opens the panel (see *Where did the rest of my chats go*). **Under 60 columns** — the phone shape — home is an inbox with the projects under it, and there a project other than this window's *is* one folded `▸` line, which `enter` or a tap @@ -450,7 +451,7 @@ each of those is a panel of its own now. | --- | --- | | `?` rows at the top of the list | the `needs you` panel | | `◐` rows under them | the `running` panel | -| the quiet rows and `▸ 15 more, quiet since 6d` | `where you were`, then `N more · type to find one` | +| the quiet rows and `▸ 15 more, quiet since 6d` | `where you were`, then `N more` | | project headings, `alt+g` | the `projects` panel | | the `since you left` ledger above the list | the `since you left` panel | | the card on the right | nothing at rest; a card still stands beside a search on a wide frame | @@ -470,7 +471,7 @@ project's name into the box, so the search lists every conversation in it — an ones out of the way: `needs you` and `running` hold what wants you or is moving, and `where you were` shows this window's conversation — the last thing you typed in it on the line under it — and the four most recent, nine in a tall window, folding the rest behind -`N more · type to find one`. Typing finds any conversation on the machine. +`N more`, which `enter` opens. Typing finds any conversation on the machine. ## Which column am I in — move between the columns on home: ↑↓ walk a panel, ←→ cross columns @@ -550,13 +551,19 @@ conversation: "…"` rise out of the box, and `enter` starts the conversation. Over `--host`, in the fraction of a second before the far machine answers, home draws no panels at all — a whisper over a server full of work would be untrue. -## Where did the rest of my chats go — N more, and panels cut off on a short or small terminal +## Where did the rest of my chats go — N more, expand a panel, and panels cut off on a short or small terminal -**Every panel folds inside itself**, with how many more it is holding and the name of where -they are: `12 more · tasks` under `running`, `3 more · standing` under `next up`, -`70 more · type to find one` under `where you were`. `needs you` can say both halves — -`2 more · 3 older · tasks`. A fold that names a place is a door: `enter` on it opens the -place. +**Every panel folds inside itself** with one dim line, `N more`, counting everything it is +not showing — the rows past its budget, the rows a short window took, and on `needs you` the +landings that aged off it. **The fold is a toggle.** Walk onto it and press `enter` (or +click it): the panel opens and takes the column — every row it has, with the other panels +squeezed to their floors in the order below — and the line reads `N fewer`. `enter` again +folds it. One panel is open at a time; opening a second folds the first. An open panel +taller than the window shows what fits and its line names where the rest are — +`3 fewer · 40 more · tasks` — so nothing is left without a door. The foot under a fold says +which way it will go: `enter shows the rest`, then `enter folds them`. Opening lasts as long +as the window; a relaunch starts folded. The fold wears no mark: home spends its two marks +on the amber `?` and the one moving cell. **A tall terminal grows the panels**, once every panel has what it naturally shows: `needs you`, `running`, `since you left` and `projects` to eight rows, `where you were` from @@ -576,14 +583,15 @@ machine, including the ones no panel is drawing. ## How do I see the collapsed sessions — 13 more -**Type.** `where you were` shows the most recent conversations and then -`13 more · type to find one`: the box at the foot searches every conversation on the machine -as you type, and a project's name, a folder's name or a word from what a task came to all -find them. +**Open the fold, or type.** `where you were` shows the most recent conversations and then +`13 more`; `enter` on that line opens the panel and shows them all, squeezing the other +panels, and `enter` again folds it. The box at the foot still searches every conversation on +the machine as you type — a project's name, a folder's name or a word from what a task came +to all find them — whether or not a panel is drawing the row. -The other panels' fold lines are doors into their places — `N more · tasks` opens tasks, -`N more · standing` opens standing — and `where you were`'s is an instruction, because the -rest of your conversations are found by typing rather than by a place. +Every panel's fold works the same way: `N more` under `running`, `since you left`, `next up` +and `projects` opens that panel. The places themselves — tasks, standing, spend — are on the +tab bar and their slash commands, not behind the folds. Per-project folds — `▸ 13 more, quiet since 1d` under a project's own heading — belong only to the **phone shape**, under 60 columns, where home is still an inbox with the projects @@ -1922,8 +1930,8 @@ margin rather than unfolding, because a card is not the place that holds them; ` `▸ …3 more files`, and a click on that line — or `→` with the strip closed — opens it, and `▾ …3 fewer` folds it back. The fold always cuts between tasks, never through one. -**On the resting panels, `running` folds as `N more · tasks`**, and `enter` on that line -opens the tasks place. The phone sheet folds its work band by family and counts all the +**On the resting panels, `running` folds as `N more`**, and `enter` on that line opens the +panel. The phone sheet folds its work band by family and counts all the tasks hidden behind its fold. ## I clicked a task on home and nothing happened — open a task from the card @@ -2054,9 +2062,9 @@ at the end. Each row is your own words, with when it goes off at the right — ` never change the public API without telling me first ``` -Three rows show, five in a tall window, then `N more · standing`. Paused, stopped and retired -items are not on it. **Every row is a door into the standing place**, where the orders are -kept and changed; so is the fold line. +Three rows show, five in a tall window, then `N more`, which `enter` opens. Paused, stopped +and retired items are not on it. **Every row is a door into the standing place**, where the +orders are kept and changed. With nothing standing it keeps its heading and `reminders and routines · "remind me at 6" or "every morning at 9"` — the words that set one diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index f115cf222c..8da3097651 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -2026,8 +2026,8 @@ of the list down by its own height, and while that strip is drawn its letters ar and the box is asleep — `y`/`n` in a question's own words, `a put it away`, `t new chat here`, `o open folder`, `c copy path`, `p pause it` or `r resume it` on a standing item. `esc` or `←` closes it, `enter` still opens the row, and walking off the row closes it too. On a row -with no verbs the arrows cross home's columns, and on a fold line (`N more · tasks`, -`N more · type to find one`) `enter` opens the place or the search that holds the rest. +with no verbs the arrows cross home's columns, and on a panel's fold line (`N more`) `enter` +opens the panel and shows the rest; on `N fewer` it folds them again. While something is typed the two arrows move the caret in the box instead. **A letter always types**, unless the verb strip that names it is on screen — that visible diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index 5009ae8b28..c6598b9165 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -2273,7 +2273,8 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { {"how do I move between the columns on home", "home"}, {"why is the cursor on my previous chat when I open home", "home"}, {"why did home cut off panels on a small terminal", "home"}, - {"what does 3 older tasks mean on home", "home"}, + {"what does 3 more mean on home", "home"}, + {"how do I expand a panel on home to see the rest", "home"}, {"where did the card on home go", "home"}, {"where did standing and memory go from the tab bar", "home"}, {"can I stop another window task from home", "home"}, diff --git a/internal/tui3/home.go b/internal/tui3/home.go index 84824c1fd4..cda2fa6daa 100644 --- a/internal/tui3/home.go +++ b/internal/tui3/home.go @@ -797,6 +797,13 @@ type homeView struct { // The phone tier's own state (homephone.go, homesheet.go): the sheet over // the inbox, the triage sections somebody folded, the machine's news as the // `since you left` section reads it, and where the action bar landed. + // opened is the one grid panel whose fold somebody opened, and openedOn + // that there is one — the accordion: one panel at a time takes the column + // and the rest squeeze (homegrid.go's [homeGridPanel.fold]). It lives as + // long as the window; a relaunch starts folded. + opened homePanelID + openedOn bool + phone bool sheet homeSheet sheetHits []homeSheetHit @@ -2005,6 +2012,9 @@ func (l homeLine) sameRow(other homeLine) bool { return l.item.ID != "" && l.item.ID == other.item.ID case homeQuiet, homeItemFold, homeProject, homeProjectRow: return l.dir != "" && l.dir == other.dir + // a grid panel's fold: one per panel, told apart by the panel. + case homeFold: + return l.cell != nil && other.cell != nil && l.cell.panel == other.cell.panel case homeExchangeRow: return l.ex != nil && l.ex == other.ex // the router's lane: an offered place and an offered command @@ -2325,7 +2335,7 @@ func (h *homeView) itemLine(project session.Project, view StandingItemView) home func (l homeLine) stop() bool { switch l.kind { case homeSession, homeQuiet, homeAction, homeItem, homeItemFold, homeAskHere, - homeProject, homeExchangeRow, homeProjectRow: + homeProject, homeExchangeRow, homeProjectRow, homeFold: return true // the router's lane: an offered place is a door like every other door on this // column (homeplaces.go), and an offered command is one too (homeslash.go). @@ -3119,6 +3129,12 @@ func (a *app) homeEnter() tea.Cmd { case homeItemFold: h.foldItems(line.dir, line.folded) return nil + case homeFold: + // A PANEL'S FOLD OPENS THE PANEL, AND SHUTS IT AGAIN (homegrid.go's + // [homeGridPanel.fold]). The cursor stays on the fold whichever way it + // went, so the gesture reverses without a walk. + h.toggleFold(line) + return nil case homeProject: // A WHOLE PROJECT, OPENED WHERE IT STANDS. enter is the same key it is on // every other fold on this column, and it is the only thing enter can mean @@ -5389,6 +5405,11 @@ func (a *app) homeHintWords() string { // on all of them too ([app.homeCrossChord]). The rows' own sentences // below still serve the phone and the filtered list, where there is no // grid to be consistent across. + case line.kind == homeFold: + // The panel's fold is a toggle and the foot says which way it will go; + // the words are the ones every fold door on every place uses + // (placeprose.go's [foldEnterWord]). + return foldEnterWord(!line.folded) + " · esc close" case line.kind == homeQuiet && line.folded: return "enter or → show them · esc close" case line.kind == homeQuiet: @@ -5490,3 +5511,27 @@ func homeProjectPath(project session.Project) string { } return project.Dir } + +// toggleFold opens the panel whose fold this is, or shuts the one that is open +// — ONE AT A TIME: opening a second panel shuts the first, because two open +// panels on one column would be two panels fighting the squeeze for the same +// rows and the fold line of each would be lying about what it could show. The +// cursor is put back on the same panel's fold, which has moved. +func (h *homeView) toggleFold(line homeLine) { + if line.cell == nil { + return + } + panel := line.cell.panel + if h.openedOn && h.opened == panel { + h.openedOn = false + } else { + h.opened, h.openedOn = panel, true + } + h.rebuild() + for at, l := range h.lines { + if l.kind == homeFold && l.cell != nil && l.cell.panel == panel { + h.cursor = at + return + } + } +} diff --git a/internal/tui3/homegrid.go b/internal/tui3/homegrid.go index 901c74cbee..b39cdb9048 100644 --- a/internal/tui3/homegrid.go +++ b/internal/tui3/homegrid.go @@ -1,6 +1,7 @@ package tui3 import ( + "math" "sort" "strings" "time" @@ -193,10 +194,6 @@ const homeFindWord = "type to find one" // `N older · tasks` instead ([needsFresh]). const homeNeedsTaskFresh = 48 * time.Hour -// homeFoldOlderWord is what the fold says after the count of rows a panel aged -// out rather than folded. -const homeFoldOlderWord = "older" - // homeWhisper is what an empty panel says under its heading — THE COPY OF // RECORD is DESIGN.md §4, and the manual quotes it from here. // @@ -218,19 +215,33 @@ var homeWhisper = map[homePanelID]string{ panelNext: `reminders and routines · "remind me at 6" or "every morning at 9"`, } -// homePanelCut is a panel's rows cut at its growth budget, with the count of -// what the fold stands for past it. Which of the kept rows are drawn is the -// layout's to say ([homeGridLayout]); a panel only ever hands it at most this -// many, so a machine with four hundred conversations builds ten lines and not -// four hundred. -func homePanelCut(id homePanelID, lines []homeLine) homePanelRows { - most := homeSlotOf(id).most +// homePanelCut is a panel's rows cut at its cap, with the count of what the +// fold stands for past it. Which of the kept rows are drawn is the layout's to +// say ([homeGridLayout]); a panel only ever hands it at most this many, so a +// machine with four hundred conversations builds ten lines and not four hundred +// — unless it is THE panel somebody opened, whose cap is lifted +// ([homeGridInput.cap]). +func homePanelCut(in *homeGridInput, id homePanelID, lines []homeLine) homePanelRows { + most := in.cap(id) if len(lines) <= most { return homePanelRows{lines: lines} } return homePanelRows{lines: lines[:most], more: len(lines) - most} } +// cap is how many rows a panel hands the layout: its growth budget, the order +// table's `most` — or every row it has, for the one panel whose fold somebody +// opened ([homeView.opened]). The layout still fits that panel to the column, +// so opening a panel with four hundred rows on a forty-row frame shows the +// forty and folds the rest; what the lifted cap buys is that the rows are +// THERE to be shown when the column has room. +func (in *homeGridInput) cap(id homePanelID) int { + if in.openedOn && in.opened == id { + return math.MaxInt + } + return homeSlotOf(id).most +} + // homeSlotOf is one panel's row of the table. func homeSlotOf(id homePanelID) homePanelSlot { for _, slot := range homePanelOrder { @@ -319,6 +330,11 @@ type homeGridInput struct { spend homeSpendReading seen time.Time now time.Time + // opened is the one panel whose fold somebody opened, and openedOn that + // there is one: its cap is lifted ([homeGridInput.cap]) and, for `needs + // you`, its aged landings come back into the group ([homeView.gridInput]). + opened homePanelID + openedOn bool // desc is that this frame HAS a description column ([homeDescOn]). A row // whose sentence only exists to be drawn there does not carry one on a frame // with nowhere to draw it — a sub reserves a line in its panel's height @@ -335,7 +351,16 @@ func (h *homeView) gridInput() homeGridInput { 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) calls, older := needsFresh(needsCalls(world, h.world.Read), h.world.Read) + // AN OPENED `needs you` SHOWS ITS AGED LANDINGS TOO. They aged out of the + // group as history ([needsFresh]) and the shut fold counts them with the rest + // it hides; opening the fold is asking for all of it, and a `▸ 3 more` that + // opened to show one row and kept two behind another word would be a fold + // that lied about its own count. + if h.openedOn && h.opened == panelNeeds { + calls, older = needsCalls(world, h.world.Read), 0 + } return homeGridInput{rows: reading.rows, ledger: reading.ledger, calls: calls, callsOlder: older, + opened: h.opened, openedOn: h.openedOn, desc: homeDescOn(h.cols), 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} @@ -542,11 +567,21 @@ type homeGridPanel struct { // group that lives there and the panels under it as the ones that are only // passing through ([homeGridLayout]). gapAbove bool + // expanded is the panel whose fold somebody opened: it wants every row it + // has, the squeeze takes from it last, and its fold is the way back + // ([homeGridPanel.fold]). + expanded bool } -// natural is how many rows the panel shows at its natural height: its rest, -// or all it holds when that is fewer. -func (p homeGridPanel) natural() int { return min(p.slot.rest, len(p.read.lines)) } +// natural is how many rows the panel shows at its natural height: its rest, or +// all it holds when that is fewer — and all it holds, however many, for the +// panel somebody opened. +func (p homeGridPanel) natural() int { + if p.expanded { + return len(p.read.lines) + } + return min(p.slot.rest, len(p.read.lines)) +} // empty reports a panel with no rows at all, which draws its whisper instead. // A panel whose every row aged out is not empty: its fold is the door to them. @@ -557,8 +592,9 @@ func (p homeGridPanel) empty() bool { // hidden is how many rows the fold stands for. func (p homeGridPanel) hidden() int { return len(p.read.lines) - p.shown + p.read.more } -// folds reports that the panel draws its fold line. -func (p homeGridPanel) folds() bool { return p.hidden() > 0 || p.read.older > 0 } +// folds reports that the panel draws its fold line: while it is hiding rows, +// and while it is open — an open panel's fold is the way to shut it again. +func (p homeGridPanel) folds() bool { return p.hidden() > 0 || p.read.older > 0 || p.expanded } // height is how many screen rows the panel draws: its heading, its rows or its // whisper, and its fold. @@ -665,10 +701,18 @@ func fitColumn(column []*homeGridPanel, room int) { } // byKeep is a column's panels in the order a squeeze takes from them: the -// lowest keep first. +// lowest keep first — and THE OPEN PANEL LAST, whatever its keep. Somebody +// asked to see all of it, so every other panel goes to its floor before that +// one gives up a row; and since the regrow hands rows back from the end of this +// order, it is also the first to get them. func byKeep(column []*homeGridPanel) []*homeGridPanel { order := append([]*homeGridPanel(nil), column...) - sort.SliceStable(order, func(i, j int) bool { return order[i].slot.keep < order[j].slot.keep }) + sort.SliceStable(order, func(i, j int) bool { + if order[i].expanded != order[j].expanded { + return order[j].expanded + } + return order[i].slot.keep < order[j].slot.keep + }) return order } @@ -800,7 +844,7 @@ func homeGridLayout(in *homeGridInput, cols, width, room int) [][]*homeGridPanel rail := homeRailCol(cols) for _, slot := range homePanelOrder { read := slot.panel.rows(in) - p := &homeGridPanel{slot: slot, read: read} + p := &homeGridPanel{slot: slot, read: read, expanded: in.openedOn && in.opened == slot.panel.id()} p.shown = p.natural() p.desc = homeDescOn(cols) at := homeColumnOf(slot, cols, p.empty()) @@ -984,30 +1028,57 @@ func (p homeGridPanel) lines() []homeLine { return out } -// fold is `N more · <place>` (law 9: nothing grows, and the fold is the door), -// with `M older` between the two when the panel aged rows out. A fold that names -// a place is a door into it, the same [homeLedger] line a `since you left` row -// is; one that names only a count or an instruction is not a stop. +// fold is the panel's last line, and IT IS A TOGGLE (owner, 2026-09-15). Shut, +// it is `N more`, counting everything the panel is not showing — the rows past +// its budget, the rows the squeeze took, and the landings `needs you` aged out +// — and enter opens the panel: its cap is lifted, the other panels squeeze to +// their floors (law 5), and it takes the column. Open, it is `N fewer`, and +// enter shuts it again. +// +// IT WEARS NO MARK. The places' fold doors are `▸ 11 more` and `▾ 11 fewer` +// ([foldDoor]); this one is the words alone, because the grid has two marks and +// no other (law 8) — the amber question and the one moving cell — and a third +// glyph down the column, one per panel, would be the thing that law exists to +// refuse. The words say which way it goes: `more` opens, `fewer` shuts. +// +// THE PLACE IS NAMED ONLY FOR WHAT STILL WILL NOT FIT. An open panel taller +// than the column shows what it can and its fold reads `3 fewer · 40 more · +// tasks` — the way back first, then where the rest are — because a fold that +// opened and still hid rows without saying where they were would leave them +// nowhere. It used to be the other way round: law 9 said the fold IS the door, +// `N more · tasks` opened the tasks place, and `where you were`'s `N more · type +// to find one` was not a stop at all because typing was its door; the owner +// found one line that opened somewhere and another that could not be stood on +// and asked for one thing that expands. func (p homeGridPanel) fold() homeLine { - var counts []string - if n := p.hidden(); n > 0 { - counts = append(counts, groupedInt(n)+" "+p.foldMoreWord()) - } - if n := p.read.older; n > 0 { - counts = append(counts, groupedInt(n)+" "+homeFoldOlderWord) - } - words := strings.Join(counts, rowSep) - word := p.slot.foldWord() - if word != "" { - words += rowSep + word + words := "" + rest := p.hidden() + p.read.older + if p.expanded { + words = foldWords(true, p.opened(), "") + if rest > 0 { + words += rowSep + groupedInt(rest) + " " + p.foldMoreWord() + rowSep + p.slot.foldWord() + } + } else { + // The group's word where the group's own line is not on the screen, + // so `8 unread` is still the whole of what a squeezed panel says about + // its landings ([homeGridPanel.foldMoreWord]). + words = groupedInt(rest) + " " + p.foldMoreWord() } cell := &homeCell{kind: cellFold, panel: p.slot.panel.id(), title: words} - if p.slot.more == "" && word != "" { - return homeLine{kind: homeLedger, project: word, dir: homeFoldKey, cell: cell} - } - return homeLine{kind: homeSwitchHead, cell: cell} + return homeLine{kind: homeFold, project: p.slot.foldWord(), dir: homeFoldKey, folded: !p.expanded, quiet: rest, cell: cell} +} + +// opened is how many rows opening the panel showed that its resting height +// would not have: what the open fold's `N fewer` counts, and never fewer than +// one, because a fold that reads `0 fewer` is a line about nothing. +func (p homeGridPanel) opened() int { + return max(1, p.shown-min(p.slot.rest, len(p.read.lines))) } +// homeFold is a panel's fold line on the grid ([homeGridPanel.fold]): a stop, +// a toggle on enter, and the same line whichever way it is standing. +const homeFold homeRowKind = 245 + // 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" diff --git a/internal/tui3/homegrid_test.go b/internal/tui3/homegrid_test.go index 5b25ccccf8..7f4d3d624d 100644 --- a/internal/tui3/homegrid_test.go +++ b/internal/tui3/homegrid_test.go @@ -2,6 +2,7 @@ package tui3 import ( "fmt" + "path/filepath" "strings" "testing" "time" @@ -472,7 +473,7 @@ func TestATallFrameShowsTenOfWhereYouWereAndFoldsTheRest(t *testing.T) { if got := len(panelRows(a, panelRecent)); got != homeSlotOf(panelRecent).most { t.Fatalf("where you were drew %d rows at 120×55, want %d:\n%s", got, homeSlotOf(panelRecent).most, frame) } - if row, _ := homeRowOf(frame, "66 more · "+homeFindWord); row < 0 { + if row, _ := homeRowOf(frame, "66 more"); row < 0 { t.Fatalf("the fold does not count the other sixty-six:\n%s", frame) } a.width, a.height = 120, 24 @@ -812,3 +813,107 @@ func TestASinceYouLeftRowRestsOnTheOneFootAndOpensItsConversationsFolder(t *test t.Fatalf("ctrl+o on a landing opened %q, want the folder of the conversation that ran it", opened) } } + +// A PANEL'S FOLD IS A TOGGLE (owner, 2026-09-15). `66 more` under `where you +// were` is a stop; enter on it opens the panel — every conversation the column +// can hold, the other panels squeezed to their floors — and the fold now reads +// `N fewer`, with the cursor still on it. Enter again shuts it. It used to be +// `66 more · type to find one`, which could not be stood on at all. +func TestEnterOnAFoldOpensThePanelAndAgainShutsIt(t *testing.T) { + lab := newHomeLab(t) + alpha := filepath.Join(lab.root, "alpha") + now := time.Now() + mine := lab.session("-alpha", "aaaa000000000001", "the one I am in", alpha, now) + for i := 0; i < 30; i++ { + id := fmt.Sprintf("bbbb%012d", i+1) + lab.session("-alpha", id, fmt.Sprintf("older chat %d", i), alpha, now.Add(-time.Duration(i+1)*time.Hour)) + } + a := lab.app(mine) + a.width, a.height = 120, 55 + a.openHome() + frame := homeText(a) + shown := len(panelRows(a, panelRecent)) + if shown != homeSlotOf(panelRecent).most { + t.Fatalf("where you were drew %d rows at rest, want its budget of %d:\n%s", shown, homeSlotOf(panelRecent).most, frame) + } + a.home.cursor = homeFoldDoor(t, a, panelRecent) + if !strings.HasSuffix(a.home.lines[a.home.cursor].cell.title, " more") { + t.Fatalf("the shut fold reads %q, want `N more`", a.home.lines[a.home.cursor].cell.title) + } + if hint := a.homeHint(); !strings.Contains(hint, foldEnterWord(false)) { + t.Fatalf("the foot on a shut fold is %q, want it to say %q", hint, foldEnterWord(false)) + } + drive(t, a, key("enter")) + frame = homeText(a) + if !a.at(pageHome) { + t.Fatal("enter on the fold left home") + } + if opened := len(panelRows(a, panelRecent)); opened <= shown { + t.Fatalf("opening the fold showed %d rows, no more than the %d at rest:\n%s", opened, shown, frame) + } + fold := a.home.lines[a.home.cursor] + if fold.kind != homeFold || fold.cell.panel != panelRecent || !strings.Contains(fold.cell.title, " fewer") { + t.Fatalf("after opening, the cursor is on %+v, want the same panel's fold reading `N fewer`", fold.cell) + } + if hint := a.homeHint(); !strings.Contains(hint, foldEnterWord(true)) { + t.Fatalf("the foot on an open fold is %q, want it to say %q", hint, foldEnterWord(true)) + } + drive(t, a, key("enter")) + if a.home.openedOn || len(panelRows(a, panelRecent)) != shown { + t.Fatalf("enter on the open fold did not shut it:\n%s", homeText(a)) + } +} + +// ONE PANEL IS OPEN AT A TIME. Opening a second shuts the first, and the first's +// fold says `more` again. Ten questions fold `needs you` and ten running tasks +// fold `running`, on one frame. +func TestOpeningASecondFoldShutsTheFirst(t *testing.T) { + l := newLiveLab(t) + beta := l.workspace("beta") + for i := 0; i < 10; i++ { + id := fmt.Sprintf("cccc%012d", i+1) + l.session("-beta", id, "question "+itoa(i), beta, l.now.Add(-time.Duration(i+1)*time.Hour)) + l.live("-beta", id, session.SessionPresence{State: session.PresenceWaiting, + Question: consentQuestionAt(7, "needs your ok to run bash", l.now.Add(-time.Duration(i+1)*time.Hour))}) + } + var tasks []session.PresenceTask + for i := 0; i < 10; i++ { + tasks = append(tasks, session.PresenceTask{ID: itoa(i + 1), Title: "part " + itoa(i+1), State: "running", + StartedAt: l.now.Add(-time.Duration(i+1) * time.Minute)}) + } + l.live("-alpha", "aaaa000000000002", session.SessionPresence{RunningTasks: tasks}) + a := l.open() + a.width, a.height = 180, 60 + homeText(a) + a.home.cursor = homeFoldDoor(t, a, panelNeeds) + drive(t, a, key("enter")) + if !a.home.openedOn || a.home.opened != panelNeeds { + t.Fatal("enter did not open needs you") + } + a.home.cursor = homeFoldDoor(t, a, panelRunning) + drive(t, a, key("enter")) + if !a.home.openedOn || a.home.opened != panelRunning { + t.Fatalf("opening running left %v open", a.home.opened) + } + if fold := a.home.lines[homeFoldDoor(t, a, panelNeeds)]; !strings.Contains(fold.cell.title, " more") { + t.Fatalf("the first panel's fold still reads %q after a second was opened", fold.cell.title) + } +} + +// AN OPEN PANEL TALLER THAN THE COLUMN NAMES WHERE THE REST ARE. On a short +// frame the opened `needs you` shows what fits and its fold reads `N fewer · M +// more · tasks` — the way back first, then the place that holds the rest — so +// nothing an open fold could not show is left with no door. +func TestAnOpenFoldOnAShortFrameStillNamesThePlaceForTheRest(t *testing.T) { + lab := newSwitchLab(t) + for i := 0; i < 9; i++ { + lab.presence("-beta", "cccc00000000000"+string(rune('1'+i)), session.PresenceWaiting, "question "+itoa(i), lab.now) + } + a := lab.open(120, 20) + a.home.cursor = homeFoldDoor(t, a, panelNeeds) + drive(t, a, key("enter")) + fold := a.home.lines[homeFoldDoor(t, a, panelNeeds)].cell.title + if !strings.Contains(fold, " fewer · ") || !strings.HasSuffix(fold, " more · "+pageTasks.word()) { + t.Fatalf("the open fold on a short frame reads %q, want `N fewer · M more · tasks`", fold) + } +} diff --git a/internal/tui3/homeneedsgroups_test.go b/internal/tui3/homeneedsgroups_test.go index 4071781d2f..3d9f7e782d 100644 --- a/internal/tui3/homeneedsgroups_test.go +++ b/internal/tui3/homeneedsgroups_test.go @@ -184,13 +184,13 @@ func TestToCheckFoldsBeforeANeedsYouRowGoes(t *testing.T) { t.Fatalf("the question gave way before the landings did:\n%s", frame) } // The group's own line is the word on a line of its own; the fold that - // stands for it says `3 unread · tasks`, and only the fold may be here. + // stands for it says `3 unread`, and only the fold may be here. for _, row := range strings.Split(frame, "\n") { if strings.TrimSpace(row) == needsCheckWord { t.Fatalf("the group line survived the squeeze:\n%s", frame) } } - if !strings.Contains(frame, "3 "+needsCheckWord+" · tasks") { + if !strings.Contains(frame, "3 "+needsCheckWord) || strings.Contains(frame, needsCheckWord+" · tasks") { t.Fatalf("the fold does not say what the folded group is:\n%s", frame) } } diff --git a/internal/tui3/homepanel_left.go b/internal/tui3/homepanel_left.go index 76307fa2e5..8b0a5f8370 100644 --- a/internal/tui3/homepanel_left.go +++ b/internal/tui3/homepanel_left.go @@ -37,7 +37,7 @@ func (leftPanel) rows(in *homeGridInput) homePanelRows { } lines = append(lines, leftLine(row, in.now)) } - out := homePanelCut(panelLeft, lines) + out := homePanelCut(in, panelLeft, lines) if len(lines) > 0 { out.said = sinceAt(in.seen, in.now) } diff --git a/internal/tui3/homepanel_live_test.go b/internal/tui3/homepanel_live_test.go index 8baeff193a..6bae54a3aa 100644 --- a/internal/tui3/homepanel_live_test.go +++ b/internal/tui3/homepanel_live_test.go @@ -294,16 +294,24 @@ func TestNeedsYouAgesOldCallsOntoTheFold(t *testing.T) { } frame := homeText(a) // AND NEITHER THE HEADING NOR THE GROUP LINE COUNTS ANYTHING: the rows are - // under them. The one count is the fold's, for the aged landings that are - // not on the screen. + // under them. The one count is the fold's, which counts the aged landings + // with everything else it hides — `3 more`, no longer `3 older · tasks`. 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) || strings.Contains(frame, needsCheckWord+" · ") { t.Fatalf("the group line says more than its name:\n%s", frame) } - if !strings.Contains(frame, "3 older · tasks") { - t.Fatalf("the fold does not count what aged:\n%s", frame) + fold := a.home.lines[homeFoldDoor(t, a, panelNeeds)].cell.title + if fold != "3 more" { + t.Fatalf("the fold reads %q, want the three aged landings counted as `3 more`", fold) + } + // AND OPENING THE FOLD BRINGS THEM BACK: a fold that opened to show nothing + // of what it counted would have lied about its own number. + a.home.cursor = homeFoldDoor(t, a, panelNeeds) + drive(t, a, key("enter")) + if rows := panelRows(a, panelNeeds); len(rows) != 5 { + t.Fatalf("opening needs you drew %d rows, want all five landings:\n%s", len(rows), homeText(a)) } } @@ -527,7 +535,7 @@ func TestRunningGrowsToItsBudgetAndFoldsTheRestIntoTasks(t *testing.T) { if rows, most := panelRows(a, panelRunning), homeSlotOf(panelRunning).most; len(rows) != most { t.Fatalf("running drew %d rows, want its budget of %d", len(rows), most) } - if frame := homeText(a); !strings.Contains(frame, "2 more · tasks") || !strings.Contains(frame, "running · 10") { + if frame := homeText(a); !strings.Contains(frame, "2 more") || strings.Contains(frame, "more · tasks") || !strings.Contains(frame, "running · 10") { t.Fatalf("the fold does not name what it holds:\n%s", frame) } } diff --git a/internal/tui3/homepanel_needs.go b/internal/tui3/homepanel_needs.go index 11377e3f77..77e70244c3 100644 --- a/internal/tui3/homepanel_needs.go +++ b/internal/tui3/homepanel_needs.go @@ -84,7 +84,7 @@ func (needsPanel) rows(in *homeGridInput) homePanelRows { for _, item := range append(asked, calls...) { lines = append(lines, item.line) } - out := homePanelCut(panelNeeds, lines) + out := homePanelCut(in, panelNeeds, lines) // NEITHER THE HEADING NOR THE GROUP LINE CARRIES A COUNT. The heading used // to count the live questions (`needs you · 2`) and the group line the // landings (`to check · 8`); the owner cut both on 2026-09-15, because the diff --git a/internal/tui3/homepanel_next.go b/internal/tui3/homepanel_next.go index e0097b8603..ada76a2d74 100644 --- a/internal/tui3/homepanel_next.go +++ b/internal/tui3/homepanel_next.go @@ -16,7 +16,7 @@ type nextPanel struct{ homePanelBase } func (nextPanel) rows(in *homeGridInput) homePanelRows { views := nextActive(in) standByNextDue(views) - shown := min(homeSlotOf(panelNext).most, len(views)) + shown := min(in.cap(panelNext), len(views)) lines := make([]homeLine, 0, shown) for _, view := range views[:shown] { item := view.Item diff --git a/internal/tui3/homepanel_projects.go b/internal/tui3/homepanel_projects.go index eb417b8dbf..261af0b8df 100644 --- a/internal/tui3/homepanel_projects.go +++ b/internal/tui3/homepanel_projects.go @@ -29,7 +29,7 @@ const homeProjectRow homeRowKind = 244 func (projectsPanel) rows(in *homeGridInput) homePanelRows { ordered := projectsOrdered(in) - shown := min(homeSlotOf(panelProjects).most, len(ordered)) + shown := min(in.cap(panelProjects), len(ordered)) pad := 0 for _, project := range ordered[:shown] { pad = max(pad, min(homeProjectPad, len([]rune(projectWord(project, in.tilde))))) diff --git a/internal/tui3/homepanel_recent.go b/internal/tui3/homepanel_recent.go index 306917320a..a23686079b 100644 --- a/internal/tui3/homepanel_recent.go +++ b/internal/tui3/homepanel_recent.go @@ -40,7 +40,7 @@ func (recentPanel) rows(in *homeGridInput) homePanelRows { lines := append([]homeLine(nil), in.errands...) // The panel hands the layout as many conversations as its budget in the // order table, and the layout draws five of them or, in a tall frame, more. - room := homeSlotOf(panelRecent).most + room := in.cap(panelRecent) if own != nil { lines = append(lines, switcherRowLine(*own, recentOwnCell(*own, in))) room-- diff --git a/internal/tui3/homepanel_running.go b/internal/tui3/homepanel_running.go index 609923d96a..ad6c8fa9eb 100644 --- a/internal/tui3/homepanel_running.go +++ b/internal/tui3/homepanel_running.go @@ -76,7 +76,7 @@ func (runningPanel) rows(in *homeGridInput) homePanelRows { if len(lines) > 0 { lines[0].cell.mark = cellMarkSpin } - out := homePanelCut(panelRunning, lines) + out := homePanelCut(in, panelRunning, lines) out.said = countWord(len(lines)) return out } diff --git a/internal/tui3/homepanel_test.go b/internal/tui3/homepanel_test.go index f739209ac9..63395d0549 100644 --- a/internal/tui3/homepanel_test.go +++ b/internal/tui3/homepanel_test.go @@ -490,7 +490,7 @@ func TestNextUpIsSoonestFirstAndFoldsIntoStanding(t *testing.T) { if first < 0 || second != first+1 || !strings.Contains(strings.Split(frame, "\n")[first], " in 1h") { t.Fatalf("next up is not soonest first with its clause:\n%s", frame) } - if row, _ := homeRowOf(frame, "1 more · standing"); row < 0 { + if row, _ := homeRowOf(frame, "1 more"); row < 0 { t.Fatalf("the fourth order is not behind the fold:\n%s", frame) } homeLineOf(t, a, func(l homeLine) bool { return l.cell != nil && l.cell.panel == panelNext && l.stop() }) diff --git a/internal/tui3/placeeverymouse_test.go b/internal/tui3/placeeverymouse_test.go index efdabfc9ff..4c5233e23a 100644 --- a/internal/tui3/placeeverymouse_test.go +++ b/internal/tui3/placeeverymouse_test.go @@ -286,7 +286,10 @@ func TestAClickOnARowIsEnterOnEveryPlace(t *testing.T) { name string shape func(t *testing.T, a *app) }{ - {"as opened", func(*testing.T, *app) {}}, + // THE FRAME IS DRAWN ONCE BEFORE THE WALK on this shape too: home lays + // its lines out for the room it is drawn in, and a list built before + // any frame has a fold where the drawn list has a row. + {"as opened", func(t *testing.T, a *app) { _ = placeFrameText(a) }}, {"squeezed and scrolled", func(t *testing.T, a *app) { a.width, a.height = 60, 16 // THE FRAME IS DRAWN ONCE AT THE NEW SIZE BEFORE THE WALK, as the @@ -522,10 +525,10 @@ func homeHeadingAt(a *app, word string) (x, y int, ok bool) { return 0, 0, false } -// A FOLD THAT NAMES A PLACE IS `enter` ON ONE CLICK. Ten questions are more than -// `needs you` holds, so its fold says `2 more · tasks` — and a press on it lands -// where walking onto it and pressing enter does, not on a selection that waits -// for a second press. +// A FOLD IS `enter` ON ONE CLICK. Ten questions are more than `needs you` holds, +// so its fold says `2 more` — and a press on it opens the panel exactly as +// walking onto it and pressing enter does, not a selection that waits for a +// second press. func TestAClickOnAHomeFoldIsEnterOnIt(t *testing.T) { open := func() *app { lab := newSwitchLab(t) @@ -539,12 +542,12 @@ func TestAClickOnAHomeFoldIsEnterOnIt(t *testing.T) { homeClickAt(t, clicked, homeFoldDoor(t, clicked, panelNeeds)) keyed.home.cursor = homeFoldDoor(t, keyed, panelNeeds) drive(t, keyed, key("enter")) - if keyed.page != pageTasks { - t.Fatalf("enter on the fold of `needs you` landed on %q, want tasks", keyed.page.word()) + if !keyed.at(pageHome) || !keyed.home.openedOn || keyed.home.opened != panelNeeds { + t.Fatalf("enter on the fold of `needs you` did not open the panel (page %q, opened %v)", keyed.page.word(), keyed.home.openedOn) } - if clicked.page != keyed.page { - t.Fatalf("a click on the fold of `needs you` landed on %q and enter on it on %q", - clicked.page.word(), keyed.page.word()) + if !clicked.at(pageHome) || clicked.home.openedOn != keyed.home.openedOn || clicked.home.opened != keyed.home.opened { + t.Fatalf("a click on the fold of `needs you` landed on %q (opened %v) and enter on it on %q (opened %v)", + clicked.page.word(), clicked.home.openedOn, keyed.page.word(), keyed.home.openedOn) } } From d825da634da12c17dbb50edfc11875792fc8b133 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Tue, 15 Sep 2026 21:32:52 -0400 Subject: [PATCH 19/23] tui3: a next up row describes what it found, not when it wakes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The margin said `in now`, the description said `every five minutes`, and the title usually said the schedule a third time. The owner asked for something better. The margin is the clock — and for a watch that has looked, the time it looked, `checked 3m ago`, since a five-minute probe is always about to be due and the finding beside it is as old as that look — and the description is the finding: the line a watch found or `found nothing`, a reminder's or routine's last outcome, `your call` and why for one stopped on a person. An order that has never woken describes nothing; the row has said its schedule once. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- .../unreleased/1046-home-field-and-rail.md | 1 + internal/manual/chat/home.md | 12 +++- internal/tui3/homeband_nextup.go | 16 ++++- internal/tui3/homepanel_next.go | 66 ++++++++++++++----- internal/tui3/homepanel_test.go | 45 +++++++++++++ 5 files changed, 120 insertions(+), 20 deletions(-) diff --git a/docs/changes/unreleased/1046-home-field-and-rail.md b/docs/changes/unreleased/1046-home-field-and-rail.md index 2fc13e17a2..c0f6f697f3 100644 --- a/docs/changes/unreleased/1046-home-field-and-rail.md +++ b/docs/changes/unreleased/1046-home-field-and-rail.md @@ -17,6 +17,7 @@ invalidates: - "The foot under a field row said that row's own thing: `ctrl+x stop it` on a running task this window holds, `enter open where it was asked · ctrl+e pause · esc close` on a standing order, `enter opens the place this happened in · esc close` on a `since you left` line, and the resting sentence with `ctrl+o open folder` on a conversation. No longer true on the grid: EVERY row under a moving heading — `needs you` and `unread`, `where you were`, `running`, `since you left`, `next up` — rests on the one sentence, `type to search or start something new · ↑↓ pick · enter open · ctrl+o open folder · tab next place`, and `ctrl+o` is true on all of them because every such row belongs to a folder — an order's workspace, the conversation a landing ran in. `ctrl+e` and `ctrl+x` still work and are on the `alt+.` map; the foot no longer names them. The phone's list and the filtered list keep the rows' own sentences." - "The right margin of a field row carried whatever fact its panel had: a landing's `3 files · 1d` beside another landing's bare `10h`, a `since you left` task's cost (`$0.42`) beside a made file's conversation name (`Pricing Site`), a conversation's project word as a tag beside its age (`infra 3h`) on rows from other folders and nothing on rows from this one, `here` on this window's own row, and a background job's project inside its title. No longer true: THE RIGHT MARGIN OF EVERY ROW OF THE FIELD IS A TIME — how long a question has waited, how long ago a landing landed, when a conversation was last spoken in, how long a task or job has run, when an order is due. Everything that is not a time is the row's description, in the middle column: a landing's file count opens its sentence (`3 files · built the flow`), a task's cost and a file's conversation are their rows' descriptions under the cursor, a conversation's project is the first clause of its description, a job's project is its description, and `here` opens the own row's description. The one exception is the word that says what `enter` will do — `another window`, `coming here`, `folder gone` — which still stands in the time's place, because it is the truth about the key. On a frame with no description column the own row says no `here` at all; its bold says it." - "Every home panel folded its tail into a door: `N more · tasks` opened the tasks place, `N more · standing` opened standing, `needs you` said `N more · M older · tasks` for the landings that aged off it, and `where you were`'s `N more · type to find one` was not a stop at all because typing was its door. No longer true: EVERY FOLD IS ONE LINE, `N more`, AND IT IS A TOGGLE. `enter` (or a click) on it opens the panel — its cap is lifted, the other panels squeeze to their floors in law 5's order, and it takes the column — and the line reads `N fewer`; enter again folds it. One panel is open at a time. `needs you`'s aged landings are counted in its `N more` and come back when it is opened. An open panel taller than the column shows what fits and its fold names the place for the rest — `3 fewer · 40 more · tasks` — the one case a fold still names a place. The fold wears no mark (law 8). The foot on a fold says `enter shows the rest` / `enter folds them`. DESIGN.md law 9 is amended and says what it used to say." + - "A `next up` row's description was the order's schedule in the person's words (`every five minutes`) beside a clock that said the same thing (`in now`), under a title that usually said it a third time. No longer true: THE DESCRIPTION IS THE FINDING — a watch's last check line or `found nothing`, a reminder's or routine's last outcome, `your call · why` for one stopped on a person, the pass's words for one mid-pass — and an order that has never woken describes nothing. And a watch that has looked says when it looked at the margin (`checked 3m ago`) rather than when it will look again; `standWhenClause` used to put `in now` there whenever a probe had a next due." - "Home's resting foot was `type to search or start something new · ↑↓ pick · enter open · tab next place` on a quiet machine. It now carries the chord too — `… · enter open · ctrl+o open folder · tab next place`. The clause was always there on a machine whose right column had rows, because §6.6 refuses to leave a door invisible when `→` crosses columns rather than opening a row's verbs; what changed is that the rail always has rows now, so the condition is always met. The design's four keys still open the sentence and `esc` is still not on it." --- diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index daf07d662e..76b29ed1d9 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -2052,16 +2052,24 @@ have. **The `next up` panel, last of the seven**: every reminder and routine this machine will act on, from every project, **soonest first**, with the rules that simply hold -at the end. Each row is your own words, with when it goes off at the right — `in 20h`, -`mon 8:30`, `holds`: +at the end. Each row is your own words, with the clock at the right — `in 20h` or `mon 8:30` +for a reminder or routine, `checked 3m ago` for a watch that has looked, `holds` for a rule: ``` next up the 6am repo watch in 20h top movers before the open mon 8:30 + tell me when CI goes red checked 3m ago never change the public API without telling me first ``` +**Under the cursor the description is what the order found, never its schedule** — the +schedule is already in your own words on the row and in the clock at its right. A watch that +has looked says the line it found, or `found nothing`; a reminder or routine that has gone +off says what its last firing came to (`done: two branches landed, both in internal/tui3`); +one that has never woken says nothing at all. An order stopped on you says `your call` and +why, and one in the middle of a pass says what the pass is doing. + Three rows show, five in a tall window, then `N more`, which `enter` opens. Paused, stopped and retired items are not on it. **Every row is a door into the standing place**, where the orders are kept and changed. diff --git a/internal/tui3/homeband_nextup.go b/internal/tui3/homeband_nextup.go index addf8e84d3..922b70ee63 100644 --- a/internal/tui3/homeband_nextup.go +++ b/internal/tui3/homeband_nextup.go @@ -83,6 +83,13 @@ func standByNextDue(views []StandingItemView) { // THE APPOINTMENT OUTRANKS THE CADENCE, because "in 2h" is the thing a person // is deciding with and "every weekday at 9" is how it got there. It is shared by // the project band and the machine's watchlist for [standByNextDue]'s reason. +// +// AND A WATCH THAT HAS LOOKED SAYS WHEN IT LOOKED, not when it will look again +// (owner, 2026-09-15). A probe on a five-minute clock is always about to be due, +// so `in now` beside it said nothing a person could use, and beside a +// description of what it found the honest time is the one the finding is as +// old as: `checked 3m ago`. A watch that has never looked still says when it +// first will. func standWhenClause(item standing.Item, now time.Time) string { // AND A RULE IS ASKED BEFORE THE CLOCK IS. A hold has no NextDue and never // will, so every clause below it would fall through to whatever cadence words @@ -91,12 +98,15 @@ func standWhenClause(item standing.Item, now time.Time) string { if item.When.Kind == standing.WhenHold { return standHoldsWord } + switch item.When.Kind { + case standing.WhenProbe, standing.WhenFile, standing.WhenIdle: + if !item.LastChecked.IsZero() { + return "checked " + sinceAt(item.LastChecked, now) + " ago" + } + } if !item.NextDue.IsZero() { return "in " + nextUpAge(item.NextDue.Sub(now)) } - if item.When.Kind == standing.WhenProbe && !item.LastChecked.IsZero() { - return "checked " + sinceAt(item.LastChecked, now) + " ago" - } return strings.TrimSpace(item.When.Words) } diff --git a/internal/tui3/homepanel_next.go b/internal/tui3/homepanel_next.go index ada76a2d74..72e0f562ac 100644 --- a/internal/tui3/homepanel_next.go +++ b/internal/tui3/homepanel_next.go @@ -2,6 +2,7 @@ package tui3 import ( "strings" + "time" "github.com/Agent-Field/codeaf/internal/standing" ) @@ -23,11 +24,11 @@ func (nextPanel) rows(in *homeGridInput) homePanelRows { lines = append(lines, homeLine{kind: homeLedger, project: pageStanding.word(), dir: item.ID, view: view, item: item, cell: &homeCell{panel: panelNext, title: strings.TrimSpace(item.Words), right: standWhenClause(item, in.now), - // WHAT WAKES IT, under the cursor. The row's right-hand clause is - // WHEN the next one is; this is the standing arrangement behind - // it, in the person's own words where they gave any, and it is - // drawn only for the row being read ([homeDescLines]). - grows: in.desc, sub: nextUpSaidOn(in, item)}}) + // WHAT IT FOUND, under the cursor. The row's right-hand clause is + // the clock; this is what the order did the last time it woke + // ([nextUpSaid]), and it is drawn only for the row being read + // ([homeDescLines]). + grows: in.desc, sub: nextUpSaidOn(in, view)}}) } return homePanelRows{lines: lines, more: len(views) - shown} } @@ -50,21 +51,56 @@ func nextActive(in *homeGridInput) []StandingItemView { return views } -// nextUpSaid is the sentence the description column draws for a `next up` row: -// the arrangement in the person's own words, and nothing at all where those -// words are the row's title already — the same sentence twice is the emptiness -// law's cousin, and the row has said it once. -func nextUpSaidOn(in *homeGridInput, item standing.Item) string { +// nextUpSaidOn is the sentence the description column draws for a `next up` +// row, and nothing on a frame with no such column. +func nextUpSaidOn(in *homeGridInput, view StandingItemView) string { if !in.desc { return "" } - return nextUpSaid(item) + return nextUpSaid(view, in.now) } -func nextUpSaid(item standing.Item) string { - said := strings.TrimSpace(item.When.Words) - if said == "" || strings.EqualFold(said, strings.TrimSpace(item.Words)) { +// nextUpSaid is THE FINDING, NOT THE CLOCK. The margin already says when the +// order next wakes, and the title is the person's own words, which usually +// carry the cadence too (`sweep the repo every morning at nine…`); so a +// description that said `every morning at nine` was the third copy of one fact +// on one row (owner, 2026-09-15: "these seem redundant"). What the row has NOT +// said is what happened the last time the order woke, and that is what a +// person deciding whether to open it wants: a watch's last finding — the line +// it found, or that it found nothing, which for a watch is the most common +// finding and a real one ([standRollup] says the same) — and a reminder's or +// routine's last outcome. An order stopped on a person says so first, and one +// in the middle of a pass says what the pass is doing, in the card's own words. +// +// THE EMPTINESS LAW REACHES IT: an order that has never woken has no finding +// and draws nothing, rather than a schedule the row has already said. +func nextUpSaid(view StandingItemView, now time.Time) string { + item := view.Item + switch { + case item.NeedsPerson != "": + return tierYourCallWord + tierReasonSep + item.NeedsPerson + case view.Running: + return standRunWord(view, now) + } + switch item.When.Kind { + case standing.WhenHold: + return "" + case standing.WhenProbe, standing.WhenFile, standing.WhenIdle: + if item.LastChecked.IsZero() { + return "" + } + if found := switcherFirstLine(item.LastCheckLine); found != "" { + return found + } + return nextUpFoundNothingWord + } + if item.LastFired.IsZero() { return "" } - return said + return switcherFirstLine(item.LastOutcome) } + +// nextUpFoundNothingWord is a watch that looked and found nothing, said as a +// sentence because it stands alone in the description column — the rollup's +// bare `nothing` reads as a gap there rather than as the finding it is. +const nextUpFoundNothingWord = "found nothing" diff --git a/internal/tui3/homepanel_test.go b/internal/tui3/homepanel_test.go index 63395d0549..fb10afe4f0 100644 --- a/internal/tui3/homepanel_test.go +++ b/internal/tui3/homepanel_test.go @@ -499,3 +499,48 @@ func TestNextUpIsSoonestFirstAndFoldsIntoStanding(t *testing.T) { t.Fatal("enter on a next-up row did not open standing") } } + +// A `next up` ROW'S DESCRIPTION IS THE FINDING, NOT THE CLOCK (owner, +// 2026-09-15). The margin says when the order next wakes — and for a watch that +// has looked, when it looked — and the description says what it found or what +// its last firing came to; an order that has never woken describes nothing, +// and the schedule is never said a second time. +func TestNextUpDescribesTheLastFindingAndNeverRepeatsTheClock(t *testing.T) { + lab := newSwitchLab(t) + a := lab.open(180, 45) + dir := a.home.world.Projects[0].Dir + watch := StandingItemView{Item: standing.Item{ID: "w", Words: "tell me when CI goes red", Status: standing.StatusActive, + When: standing.When{Kind: standing.WhenProbe, Words: "every five minutes"}, NextDue: lab.now.Add(2 * time.Minute), + LastChecked: lab.now.Add(-3 * time.Minute), LastCheckLine: "the last five runs on master are green"}} + quiet := StandingItemView{Item: standing.Item{ID: "q", Words: "watch the lockfile", Status: standing.StatusActive, + When: standing.When{Kind: standing.WhenFile, Words: "when go.sum changes"}, LastChecked: lab.now.Add(-time.Hour)}} + routine := StandingItemView{Item: standing.Item{ID: "r", Words: "sweep the repo every morning at nine", Status: standing.StatusActive, + When: standing.When{Kind: standing.WhenEvery, Words: "every morning at nine"}, NextDue: lab.now.Add(13*time.Hour + 30*time.Minute), + LastFired: lab.now.Add(-11 * time.Hour), LastOutcome: "done: two branches landed, both in internal/tui3"}} + fresh := StandingItemView{Item: standing.Item{ID: "f", Words: "remind me at six to leave", Status: standing.StatusActive, + When: standing.When{Kind: standing.WhenAt, Words: "at 6 today"}, NextDue: lab.now.Add(4*time.Hour + 30*time.Minute)}} + a.home.items = map[string][]StandingItemView{dir: {watch, quiet, routine, fresh}} + a.home.build() + rows := map[string]*homeCell{} + for _, cell := range panelRows(a, panelNext) { + rows[cell.title] = cell + } + want := []struct{ title, right, sub string }{ + {"tell me when CI goes red", "checked 3m ago", "the last five runs on master are green"}, + {"watch the lockfile", "checked 1h ago", nextUpFoundNothingWord}, + {"sweep the repo every morning at nine", "in 13h", "done: two branches landed, both in internal/tui3"}, + {"remind me at six to leave", "in 4h", ""}, + } + for _, w := range want { + cell, ok := rows[w.title] + if !ok { + t.Fatalf("next up does not draw %q:\n%s", w.title, homeText(a)) + } + if cell.right != w.right || cell.sub != w.sub { + t.Fatalf("%q reads %q / %q, want the clock %q and the finding %q", w.title, cell.right, cell.sub, w.right, w.sub) + } + if strings.Contains(cell.sub, "every") || strings.Contains(cell.sub, "at 6") { + t.Fatalf("%q repeats its schedule in its description: %q", w.title, cell.sub) + } + } +} From 448adebf522781afd2910fa75e4debcaa6b1a7dc Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Tue, 15 Sep 2026 22:06:38 -0400 Subject: [PATCH 20/23] =?UTF-8?q?tui3:=20next=20up=20is=20scheduled=20?= =?UTF-8?q?=E2=80=94=20one=20word,=20no=20clock=20at=20the=20right,=20one?= =?UTF-8?q?=20sentence=20per=20kind=20of=20order?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The heading named two of the four kinds of order under it, every row carried its clock at the margin beside a title that usually said the schedule too, and an order stopped on a person stood here as well as on needs you. The owner asked for one heading word, no time on the rows, and one standardized way per kind to say when. So: `scheduled`. A row is the person's own words and nothing at its right; its time is said once, in its description under the cursor, in the sentence its kind has — reminder · goes off tomorrow 9:00am; routine · every morning at nine · next … · last: …; watch · every five minutes · last looked 3m ago · found: …; rule · always. A stopped order is off the panel while it waits, so one thing is said once across the columns. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- .../unreleased/1046-home-field-and-rail.md | 1 + docs/design/home-mission-control/DESIGN.md | 34 ++-- internal/e2e/tuiwords_test.go | 4 +- internal/manual/chat/commands.md | 2 +- internal/manual/chat/home.md | 80 +++++---- internal/manual/chat/keeping-an-eye.md | 2 +- internal/manual/chat/keys.md | 6 +- internal/manual/chat/places.md | 2 +- internal/tui3/homegrid.go | 4 +- internal/tui3/homegrid_test.go | 29 +++- internal/tui3/homepanel_next.go | 153 +++++++++++++----- internal/tui3/homepanel_test.go | 71 +++++--- 12 files changed, 264 insertions(+), 124 deletions(-) diff --git a/docs/changes/unreleased/1046-home-field-and-rail.md b/docs/changes/unreleased/1046-home-field-and-rail.md index c0f6f697f3..372d10bc5c 100644 --- a/docs/changes/unreleased/1046-home-field-and-rail.md +++ b/docs/changes/unreleased/1046-home-field-and-rail.md @@ -18,6 +18,7 @@ invalidates: - "The right margin of a field row carried whatever fact its panel had: a landing's `3 files · 1d` beside another landing's bare `10h`, a `since you left` task's cost (`$0.42`) beside a made file's conversation name (`Pricing Site`), a conversation's project word as a tag beside its age (`infra 3h`) on rows from other folders and nothing on rows from this one, `here` on this window's own row, and a background job's project inside its title. No longer true: THE RIGHT MARGIN OF EVERY ROW OF THE FIELD IS A TIME — how long a question has waited, how long ago a landing landed, when a conversation was last spoken in, how long a task or job has run, when an order is due. Everything that is not a time is the row's description, in the middle column: a landing's file count opens its sentence (`3 files · built the flow`), a task's cost and a file's conversation are their rows' descriptions under the cursor, a conversation's project is the first clause of its description, a job's project is its description, and `here` opens the own row's description. The one exception is the word that says what `enter` will do — `another window`, `coming here`, `folder gone` — which still stands in the time's place, because it is the truth about the key. On a frame with no description column the own row says no `here` at all; its bold says it." - "Every home panel folded its tail into a door: `N more · tasks` opened the tasks place, `N more · standing` opened standing, `needs you` said `N more · M older · tasks` for the landings that aged off it, and `where you were`'s `N more · type to find one` was not a stop at all because typing was its door. No longer true: EVERY FOLD IS ONE LINE, `N more`, AND IT IS A TOGGLE. `enter` (or a click) on it opens the panel — its cap is lifted, the other panels squeeze to their floors in law 5's order, and it takes the column — and the line reads `N fewer`; enter again folds it. One panel is open at a time. `needs you`'s aged landings are counted in its `N more` and come back when it is opened. An open panel taller than the column shows what fits and its fold names the place for the rest — `3 fewer · 40 more · tasks` — the one case a fold still names a place. The fold wears no mark (law 8). The foot on a fold says `enter shows the rest` / `enter folds them`. DESIGN.md law 9 is amended and says what it used to say." - "A `next up` row's description was the order's schedule in the person's words (`every five minutes`) beside a clock that said the same thing (`in now`), under a title that usually said it a third time. No longer true: THE DESCRIPTION IS THE FINDING — a watch's last check line or `found nothing`, a reminder's or routine's last outcome, `your call · why` for one stopped on a person, the pass's words for one mid-pass — and an order that has never woken describes nothing. And a watch that has looked says when it looked at the margin (`checked 3m ago`) rather than when it will look again; `standWhenClause` used to put `in now` there whenever a probe had a next due." + - "The seventh panel was `next up · reminders & routines`, its rows carried their clock at the right (`in 20h`, `mon 8:30`, `checked 3m ago`, `holds`), and an order stopped on a person was drawn on it as well as on `needs you`. No longer true: the panel is `scheduled` — one word, no explainer, because it holds four kinds of order and the explainer named two — a row is the person's own words with NOTHING at its right, and its time is said once, in its description under the cursor, in the one sentence its kind has: `reminder · goes off tomorrow 9:00am`; `routine · every morning at nine · next tomorrow 9:00am · last: <outcome>`; `watch · every five minutes · last looked 3m ago · found: <line>` or `found nothing`; `rule · always`. Moments read `today 6:00pm`, `tomorrow 9:00am`, a weekday inside the week, a date beyond it, `now` once arrived. An order stopped on a person is off `scheduled` while it waits — it is a row of `needs you` — and paused, stopped and retired orders were never on it. The whisper names all four kinds. `standing` is not on the tab bar; `scheduled` is its summary and enter on a row opens it, which is law 10 as written." - "Home's resting foot was `type to search or start something new · ↑↓ pick · enter open · tab next place` on a quiet machine. It now carries the chord too — `… · enter open · ctrl+o open folder · tab next place`. The clause was always there on a machine whose right column had rows, because §6.6 refuses to leave a door invisible when `→` crosses columns rather than opening a row's verbs; what changed is that the rail always has rows now, so the condition is always met. The design's four keys still open the sentence and `esc` is still not on it." --- diff --git a/docs/design/home-mission-control/DESIGN.md b/docs/design/home-mission-control/DESIGN.md index 8d13543911..569dc32449 100644 --- a/docs/design/home-mission-control/DESIGN.md +++ b/docs/design/home-mission-control/DESIGN.md @@ -39,9 +39,9 @@ own keys, each a door to the place that owns it. The chat list is one panel. 70 more working · checking npm run dev · pricing-site up 3h since you left · 12h - Spark Fleet Ssh Audit · 2 hosts up, 1 not next up - made reports/apartments-minto-street.md the 6am repo watch in 20h - the 6am repo watch found nothing changed top movers before the open mon 8:30 + Spark Fleet Ssh Audit · 2 hosts up, 1 not scheduled + made reports/apartments-minto-street.md the 6am repo watch + the 6am repo watch found nothing changed top movers before the open learned 2 things about codeaf 2 rules hold ─────────────────────────────────────────────────────────────────────────────────────── @@ -51,7 +51,7 @@ own keys, each a door to the place that owns it. The chat list is one panel. The field on the left is every panel with rows, in the order table's order; the rail on the right is the pinned pair and, under a blank row, whatever is quiet -today — here `running`, `since you left` and `next up` all hold something, so the +today — here `running`, `since you left` and `scheduled` all hold something, so the rail is only its pinned top. THE SCREEN A PERSON ACTUALLY SEES MOST DAYS is the inverse of this one: one panel in the field and six headings in the rail, which is the reading that produced the 2026-09-15 amendment. @@ -96,7 +96,7 @@ is the reading that produced the 2026-09-15 amendment. force for numbers (`$0.00`, `0 tasks`, a blank age all still draw nothing) and yields for panels, because a panel that vanishes teaches nothing. The whispers are in §4 and are the only prose about the product on the screen. -5. **A short terminal squeezes in priority order.** `next up` and `spend` give +5. **A short terminal squeezes in priority order.** `scheduled` 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 its `N more` fold. A GROUP INSIDE A PANEL folds before the panel gives up a row above it: the @@ -136,12 +136,12 @@ is the reading that produced the 2026-09-15 amendment. (law 8). A tall frame hands its spare rows out — only once every panel has its natural height — in the squeeze's order reversed, one row each round: `needs you` and `running` to 8, - `where you were` from 5 to 10, `since you left` to 8, `projects` to 8, `next - up` from 3 to 5; `spend` never grows. The air that is left sits under the + `where you were` from 5 to 10, `since you left` to 8, `projects` to 8, + `scheduled` from 3 to 5; `spend` never grows. The air that is left sits under the shortest column (owner, 2026-09-10: a 55-row terminal was two short columns over thirty rows of air). The budgets are the order table's `rest`/`most`. 10. **Home is the summary of the tabs.** `running` opens tasks, `spend` opens - spend, `next up` opens standing. The bar is four places: `home tasks spend + spend, `scheduled` opens standing. The bar is four places: `home tasks spend settings`. Memory, standing and search stay reachable by their slash commands and return to the bar when they are in daily use. 11. **The pulse leaves its counts at home.** On home the pulse is the budget and @@ -179,7 +179,7 @@ Verified 2026-09-10. "On disk" means readable by any window without a live agent | where you were | `Meta.Title` (the `title` role, low tier); `Summary.LastUser` via `Peek`; `SessionRow.At`; this window's tab stack in-process | — | the existing `title` call only | | projects | `World.Project{Name, Dir, Sessions}`, `Running()`, `At()`; git status cached 5s (`homeband_repo.go`) | — | none | | spend | `usage.jsonl`; `UsageByDay`, `LastDays(14)`, `UsageByModel`; `DailyBudgetUSD` in config | one function: today against the ceiling | none | -| next up | `standing.Item{NextDue, When.Kind, LastChecked}`; `homeband_nextup.go` clause logic | — | none | +| scheduled | `standing.Item{NextDue, When.Kind, LastChecked, LastFired, LastOutcome}`; `homepanel_next.go`'s one sentence per kind | — | none | | something is wrong | `lanes/<model>.json` vendor health; `calls.jsonl` when enabled | host reachability (live 2s dial), ladder moves (narrated, not written), missing key (derivable, not stored) | none | **Zero new model calls.** The one candidate, a `receipt` role at the low tier @@ -281,7 +281,7 @@ lines would read as the screen running out of room (owner, 2026-09-10). | since you left | `what watches and tasks did while the terminal was shut` | | where you were | `your conversations · what you type below starts one` | | spend | `every chat and task is priced here` | -| next up | `reminders and routines · "remind me at 6" or "every morning at 9"` | +| scheduled | `reminders, routines, watches and rules · "remind me at 6" or "every morning at 9"` | | projects | never empty: the launch folder is always a row | ## 5. Lanes @@ -329,3 +329,17 @@ Spark, branches `home/mc-<lane>`, briefs in `spark:~/af-home-briefs/`, logs The integrator merges each into `home/mission-control`, builds `bin/codeaf` for the owner after every viewable step, and runs the full suites once, at the end, on the Spark. + +### Amended 2026-09-15 — `next up` is `scheduled`, and its rows carry no clock + +The seventh panel was `next up · reminders & routines`, and each row carried its +clock at the right (`in 20h`, `mon 8:30`, `holds`) beside a title that usually +said the schedule too. The owner renamed it `scheduled` — one word, no +explainer, because it holds four kinds of order and the explainer named two — +and struck the margin. A row is the person's own words and nothing at its +right; its time is said once, in its description under the cursor, in the one +sentence its kind has: `reminder · goes off tomorrow 9:00am`, `routine · every +morning at nine · next tomorrow 9:00am · last: …`, `watch · every five minutes +· last looked 3m ago · found: …`, `rule · always`. An order stopped on a person +is not on it: it is a row of `needs you`, and one thing is said once across the +columns. diff --git a/internal/e2e/tuiwords_test.go b/internal/e2e/tuiwords_test.go index d3352fe19a..06d67830a8 100644 --- a/internal/e2e/tuiwords_test.go +++ b/internal/e2e/tuiwords_test.go @@ -198,8 +198,8 @@ var tuiWords = map[string]tuiWord{ why: "the day and the fortnight on home, and the third word of the four-place bar", }, "homePanelNext": { - screen: "next up", - why: "the reminders and routines this machine will act on, soonest first", + screen: "scheduled", + why: "every standing order this machine will act on — reminders, routines, watches, rules — soonest first", }, "homeRunningWhisper": { screen: "work you send off with /task runs here on its own", diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index 4eaffbeb86..a8b799af1c 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -1048,7 +1048,7 @@ other place. `where you were` (this window's conversation, then the most recent, then `N more · type to find one`) and `projects` (every folder, `enter` starts a chat there) on the left; `running` (every task, job and firing watch), `since you left` (what landed while -you were away), `spend` (today and the fortnight) and `next up` (reminders, soonest first) +you were away), `spend` (today and the fortnight) and `scheduled` (standing orders, soonest first) on the right. An empty panel keeps its heading and one dim line naming what arrives there. `↑`/`↓` walk a column, `←`/`→` cross columns, `enter` opens, `esc` closes back into the diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index 76b29ed1d9..570e1e9b69 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -38,8 +38,8 @@ answering one question you would ask walking up to a colleague's desk: Spark Fleet Ssh Audit · 2 hosts up, 1 not made apartments-minto-street.md - next up - the 6am repo watch in 20h + scheduled + the 6am repo watch ``` Everything with rows in it is on the **left**, in the fixed order of the seven panels. @@ -52,7 +52,7 @@ is only ever the things that are actually going on. nothing was sent while you were looking. The foot reads `type to search or start something new · ↑↓ pick · enter open · ctrl+o open folder · tab next place`, **and it is the same sentence on every row of the field** — a conversation, a question, a running task, a -standing order on `next up`, a line of `since you left`. It does not change as the cursor +standing order on `scheduled`, a line of `since you left`. It does not change as the cursor walks, so it is something you read once. The chord is there because `→` on a field row crosses to the rail rather than opening the row's verbs, and a door you cannot see is a door you never learn; `ctrl+o` opens the folder each row belongs to — a conversation's workspace, @@ -81,7 +81,7 @@ panels below them, which are in the rail only because they are quiet today. **The rank never moves, only the side.** Within the field and within the rail the order is always `needs you`, `where you were`, `projects`, `running`, `since you left`, `spend`, -`next up` — so two panels that both fill never swap places. +`scheduled` — so two panels that both fill never swap places. One column under 110 cells, where every panel is in that one order and there is no rail; two columns from 110; three from 170, where the rail is the third and the field fills the @@ -97,7 +97,7 @@ at the left is that your eye has a single place to go. | `running` | a task, a background job, a watch firing | brings its conversation here | `work you send off with /task runs here on its own` | | `since you left` | what landed while you were away | opens the record, the file or the place | `what watches and tasks did while the terminal was shut` | | `spend` | today, the fortnight, who it went to | opens the spend place | `every chat and task is priced here` | -| `next up` | a reminder or routine, soonest first | opens the standing place | `reminders and routines · "remind me at 6" or "every morning at 9"` | +| `scheduled` | a standing order — reminder, routine, watch or rule — soonest first | opens the standing place | `reminders, routines, watches and rules · "remind me at 6" or "every morning at 9"` | **An empty panel keeps its heading and that one dim line** — it names what arrives there and the one thing that puts it there, and it never says the panel is empty. On a narrow column @@ -567,10 +567,10 @@ on the amber `?` and the one moving cell. **A tall terminal grows the panels**, once every panel has what it naturally shows: `needs you`, `running`, `since you left` and `projects` to eight rows, `where you were` from -five to ten, `next up` from three to five. `spend` never grows. What is left over is air +five to ten, `scheduled` from three to five. `spend` never grows. What is left over is air under the shorter column. -**A short terminal squeezes them in a fixed order**: `next up` gives way first, then `spend`, +**A short terminal squeezes them in a fixed order**: `scheduled` gives way first, then `spend`, then `since you left`, then `running`, then `projects`; `where you were` and `needs you` shrink last. A squeezed panel keeps its heading, the rows that fit and its `N more` line; only when every panel is down to that is a panel dropped — and the panels that are only @@ -589,7 +589,7 @@ panels, and `enter` again folds it. The box at the foot still searches every con the machine as you type — a project's name, a folder's name or a word from what a task came to all find them — whether or not a panel is drawing the row. -Every panel's fold works the same way: `N more` under `running`, `since you left`, `next up` +Every panel's fold works the same way: `N more` under `running`, `since you left`, `scheduled` and `projects` opens that panel. The places themselves — tasks, standing, spend — are on the tab bar and their slash commands, not behind the folds. @@ -640,14 +640,14 @@ cent — one reading of one file, wherever you are standing. words — `home tasks spend settings` — and `tab`, `alt+1` … `alt+4` walk them. Standing, memory and search open exactly as they did: -- **`/standing`** (or `/orders`), `alt+5`, or `enter` on a `next up` row or its fold; +- **`/standing`** (or `/orders`), `alt+5`, or `enter` on a `scheduled` row; - **`/memory`** (or `/memories`), `alt+6`, or `enter` on memory's line in `since you left`; - **`/search`**, `alt+7`, or the typed door on home's box. While you stand in one of the three, its word is drawn after the four so you can see where you are; `tab` from there goes to home. `alt+.` draws the map of all seven with their numbers. Home's own panels already summarise the three on the bar: `running` is a glimpse of -tasks, `spend` of spend, `next up` of standing. +tasks, `spend` of spend, `scheduled` of standing. ## Why did a dashboard open when I started codeaf — home greets you @@ -740,7 +740,7 @@ goes empty on the `start a new conversation` row, which is a chat that does not **One click is `enter`.** A click on a row opens it, and a click on a fold that names a place opens that place. A click on a panel's **heading** opens the place the heading names: -`needs you`, `running` and `since you left` open tasks, `spend` opens spend, `next up` opens +`needs you`, `running` and `since you left` open tasks, `spend` opens spend, `scheduled` opens standing, `where you were` opens search, and `projects` opens nothing. A click never starts a paid turn, so on the row of what you typed, on `ask here` and on a `/` command it only puts the cursor there, and `enter` does the rest. @@ -1591,11 +1591,11 @@ three ways:** - **while it is firing**, it is a row of `running`: your own words, what the pass is doing, and how long ago the pass began. It is still the item — `→` offers `pause`, and `ctrl+v` raises how hard it thinks; -- **while it is simply waiting for its time**, it is a row of `next up`, soonest first, with +- **while it is simply waiting for its time**, it is a row of `scheduled`, soonest first, with when it goes off at the right — `in 20h`, `mon 8:30`. `enter` on a `needs you` or `running` row opens the conversation that asked for it; on a -`next up` row it opens the standing place. +`scheduled` row it opens the standing place. The `◦` mark itself belongs to the standing place and to a conversation's own lines — `◦ leave for the train · in 4m`. `∙` is a paused item there, and `◆` means the thing went off @@ -1833,7 +1833,7 @@ drawn only when it has something to say: 4. **the work** — each task and what it came to, three of them, then `▸ N more tasks`; 5. **the files it made**, as links; 6. **where you left off**, the last exchange; -7. **what is next up** and **news since you last looked**; +7. **what is scheduled** and **news since you last looked**; 8. a dim line of **facts** — `touched 12 files · spent $1.25 · 34k tokens · last active 12m`; 9. one dim line naming the strip: `→ verbs: put it away, new chat here, open folder, @@ -2048,35 +2048,47 @@ timed-out Git check draws nothing. The reading arrives a moment after home opens that is no longer on this disk says `that folder is gone` in place of the branch it cannot have. -## What is next up on home — reminders and routines coming soon +## What is scheduled on home — reminders, routines, watches and rules, what is next up and when -**The `next up` panel, last of the seven**: every reminder and routine this -machine will act on, from every project, **soonest first**, with the rules that simply hold -at the end. Each row is your own words, with the clock at the right — `in 20h` or `mon 8:30` -for a reminder or routine, `checked 3m ago` for a watch that has looked, `holds` for a rule: +**The `scheduled` panel, last of the seven**: every standing order this machine will act +on, from every project, **soonest first**, with the rules that simply hold at the end. +Four kinds of order stand on it — a **reminder** (`remind me at 6`), a **routine** (`every +morning at nine`), a **watch** (`tell me when CI goes red`, `when go.sum changes`) and a +**rule** (`never change the public API without telling me`). Each row is your own words +and **nothing at its right**: ``` - next up - the 6am repo watch in 20h - top movers before the open mon 8:30 - tell me when CI goes red checked 3m ago + scheduled + the 6am repo watch + top movers before the open + tell me when CI goes red on master never change the public API without telling me first ``` -**Under the cursor the description is what the order found, never its schedule** — the -schedule is already in your own words on the row and in the clock at its right. A watch that -has looked says the line it found, or `found nothing`; a reminder or routine that has gone -off says what its last firing came to (`done: two branches landed, both in internal/tui3`); -one that has never woken says nothing at all. An order stopped on you says `your call` and -why, and one in the middle of a pass says what the pass is doing. +**When it happens is said once, in the row's description under the cursor, and each kind +says it one way** — a fixed sentence you learn once, the kind word first: -Three rows show, five in a tall window, then `N more`, which `enter` opens. Paused, stopped -and retired items are not on it. **Every row is a door into the standing place**, where the -orders are kept and changed. +- a reminder: `reminder · goes off tomorrow 9:00am` +- a routine: `routine · every morning at nine · next tomorrow 9:00am · last: done, two branches landed` +- a watch: `watch · every five minutes · last looked 3m ago · found: the last five runs are green` + — or `found nothing`, which for a watch is the commonest finding and a real one +- a rule: `rule · always` + +Moments read `today 6:00pm`, `tomorrow 9:00am`, a weekday inside the week (`mon 9:00am`), +a date beyond it (`21 sep 9:00am`), and `now` once they have arrived. A routine that has +never fired has no `last:`; a watch that has never looked has no `last looked`. An order in +the middle of a pass says what the pass is doing instead of its clock. + +**An order stopped on you is not on `scheduled`** — it is a row of `needs you`, with its +question, and comes back here the moment you answer. Paused, stopped and retired orders are +not on it either. + +Three rows show, five in a tall window, then `N more`, which `enter` opens. **Every row is a +door into the standing place**, where the orders are kept and changed. With nothing standing it keeps its heading and -`reminders and routines · "remind me at 6" or "every morning at 9"` — the words that set one -up. On a short terminal `next up` is the first panel to give way. +`reminders, routines, watches and rules · "remind me at 6" or "every morning at 9"` — the +words that set one up. On a short terminal `scheduled` is the first panel to give way. ## How much did today cost — the spend panel on home diff --git a/internal/manual/chat/keeping-an-eye.md b/internal/manual/chat/keeping-an-eye.md index b3151f973f..ef733aa3c2 100644 --- a/internal/manual/chat/keeping-an-eye.md +++ b/internal/manual/chat/keeping-an-eye.md @@ -730,7 +730,7 @@ everything you set up from it: **Home and the standing place both work over a connection**, and both are about the far machine: home's panels are that machine's — its questions, its running work, its -`next up` — and the +`scheduled` — and the standing place lists both what stands on this conversation and what stands anywhere else on that machine. `p` and `s` write to the far machine's store and the refusal, if the store refuses, is that store's own. The count at the foot of the task column — and the diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index 8da3097651..480ca1b67e 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -2005,7 +2005,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, 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 `unread` 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 | +| `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 `scheduled` row opens spend or standing, a fold line opens or shuts its panel | | `pgup` / `pgdown` | jump a screenful | | `tab` | **the next place** on the bar | | `esc` | clears the box if anything is in it, and closes home otherwise | @@ -2069,12 +2069,12 @@ still closes home from anywhere; the resting foot does not spend a cell naming i `alt+.` draws the whole map when you want it. On the grid every row under a moving heading — a conversation, a question, a running task, -a `next up` order, a `since you left` line — rests on the one sentence, +a `scheduled` order, a `since you left` line — rests on the one sentence, `type to search or start something new · ↑↓ pick · enter open · ctrl+o open folder · tab next place`, and it does not change as the cursor walks. Elsewhere, on a row with keys of its own, the foot says what THAT row's keys do and gains the two that are true everywhere — `enter opens the place this happened in · alt+. map · tab next place · esc close` on a -`since you left` line or a `next up` row of the phone's list, on a spend row, or on a fold door, and +`since you left` line or a standing order's row of the phone's list, on a spend row, or on a fold door, and `enter starts a new conversation and sends this · ↑ ask here · ↑↑ pick a match · alt+. map · tab next place · esc clear` on the action row — and `enter runs this command · ↑ ask here · ↑↑ pick a match · alt+. map · tab next place · esc clear` diff --git a/internal/manual/chat/places.md b/internal/manual/chat/places.md index 8a33ce176e..2cacdb0740 100644 --- a/internal/manual/chat/places.md +++ b/internal/manual/chat/places.md @@ -414,7 +414,7 @@ being held down — it only reports what arrived. The first place, and the one codeaf opens on. Everything on this machine, from every project, as **seven panels** in one, two or three columns: `needs you`, `where you were` and -`projects` on the left; `running`, `since you left`, `spend` and `next up` on the right. An +`projects` on the left; `running`, `since you left`, `spend` and `scheduled` on the right. An 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 diff --git a/internal/tui3/homegrid.go b/internal/tui3/homegrid.go index b39cdb9048..d1115a3fcf 100644 --- a/internal/tui3/homegrid.go +++ b/internal/tui3/homegrid.go @@ -181,7 +181,7 @@ var homePanelOrder = []homePanelSlot{ {panel: runningPanel{homePanelBase{panelRunning}}, word: "running", explainer: "work you sent off", keep: 3, least: 4, rest: 4, most: 8, place: pageTasks, head: pageTasks}, {panel: leftPanel{homePanelBase{panelLeft}}, word: "since you left", keep: 2, least: 3, rest: 4, most: 8, place: pageTasks, head: pageTasks}, {panel: spendPanel{homePanelBase{panelSpend}}, word: "spend", pinned: true, keep: 1, least: 3, rest: 3, most: 3, place: pageSpend, head: pageSpend}, - {panel: nextPanel{homePanelBase{panelNext}}, word: "next up", explainer: "reminders & routines", keep: 0, least: 3, rest: 3, most: 5, place: pageStanding, head: pageStanding}, + {panel: nextPanel{homePanelBase{panelNext}}, word: homeScheduledWord, keep: 0, least: 3, rest: 3, most: 5, place: pageStanding, head: pageStanding}, } // homeFindWord is what a fold says where the rest are reached by typing rather @@ -212,7 +212,7 @@ var homeWhisper = map[homePanelID]string{ panelLeft: "what watches and tasks did while the terminal was shut", panelRecent: "your conversations · what you type below starts one", panelSpend: "every chat and task is priced here", - panelNext: `reminders and routines · "remind me at 6" or "every morning at 9"`, + panelNext: `reminders, routines, watches and rules · "remind me at 6" or "every morning at 9"`, } // homePanelCut is a panel's rows cut at its cap, with the count of what the diff --git a/internal/tui3/homegrid_test.go b/internal/tui3/homegrid_test.go index 7f4d3d624d..35650ee759 100644 --- a/internal/tui3/homegrid_test.go +++ b/internal/tui3/homegrid_test.go @@ -152,7 +152,7 @@ func TestAtOneEightyTheFieldIsOneColumnAndTheMiddleIsNoPanels(t *testing.T) { } // AND THE QUIET PANELS ARE ALL IN THE LAST COLUMN, none of them left behind // in the field beside a panel that has something to say. - for _, word := range []string{"spend", "since you left", "next up"} { + for _, word := range []string{"spend", "since you left", homeScheduledWord} { if _, at := homeRowOf(frame, word); at != railCol { t.Fatalf("%q is at cell %d, want the rail at %d:\n%s", word, at, railCol, frame) } @@ -179,12 +179,27 @@ func TestTheDescriptionColumnCarriesTheSelectedRowsOwnSentence(t *testing.T) { said := strings.TrimSpace(line.cell.sub) frame := homeText(a) - // THE SENTENCE IS IN THE MIDDLE COLUMN and not under its row. - row, at := homeRowOf(frame, firstWordsOf(said)) + // THE SENTENCE IS IN THE MIDDLE COLUMN and not under its row. It is looked + // for THERE — at a cell past the field — because a short sentence can be a + // substring of a heading (`here` is inside `where you were`), and the + // first place the letters happen to appear is not the place the column + // draws them. + _, rail := homeRowOf(frame, "projects · ") + xs, _ := homeGridGeometry(a.home.gridWidth, a.home.cols) + descX := xs[homeDescCol(a.home.cols)] + row, at := -1, -1 + for y, text := range strings.Split(frame, "\n") { + if y < placeHeadRows { + continue + } + if i := strings.Index(text, firstWordsOf(said)); i >= 0 && ansi.StringWidth(text[:i]) >= descX { + row, at = y, ansi.StringWidth(text[:i]) + break + } + } if row < 0 { - t.Fatalf("the selected row's sentence %q is nowhere on the frame:\n%s", said, frame) + t.Fatalf("the selected row's sentence %q is nowhere in the description column:\n%s", said, frame) } - _, rail := homeRowOf(frame, "projects · ") if at <= homeGridMargin || at >= rail { t.Fatalf("the sentence is at cell %d, want it between the field at %d and the rail at %d:\n%s", at, homeGridMargin, rail, frame) } @@ -195,8 +210,10 @@ func TestTheDescriptionColumnCarriesTheSelectedRowsOwnSentence(t *testing.T) { } // AND THE ROW ITSELF IS ONE LINE: the line under it is another row, not its // own second line. + // (Only the field's cells of that line are read: the rail beside it may + // say the same word in a whisper of its own — `…priced here`.) lines := strings.Split(frame, "\n") - if title+1 < len(lines) && strings.Contains(lines[title+1], firstWordsOf(said)) { + if title+1 < len(lines) && strings.Contains(ansi.Truncate(lines[title+1], descX, ""), firstWordsOf(said)) { t.Fatalf("the row still draws its own second line:\n%s", frame) } } diff --git a/internal/tui3/homepanel_next.go b/internal/tui3/homepanel_next.go index 72e0f562ac..4b9a216baa 100644 --- a/internal/tui3/homepanel_next.go +++ b/internal/tui3/homepanel_next.go @@ -14,6 +14,11 @@ import ( // whole machine. Every row opens the standing place, where the orders are kept. type nextPanel struct{ homePanelBase } +// homeScheduledWord is the panel's heading: one word, no explainer (owner, +// 2026-09-15; it was `next up · reminders & routines`, which named two of the +// four kinds of order that stand on it). +const homeScheduledWord = "scheduled" + func (nextPanel) rows(in *homeGridInput) homePanelRows { views := nextActive(in) standByNextDue(views) @@ -21,13 +26,14 @@ func (nextPanel) rows(in *homeGridInput) homePanelRows { lines := make([]homeLine, 0, shown) for _, view := range views[:shown] { item := view.Item + // A ROW IS THE PERSON'S OWN WORDS AND NOTHING AT ITS RIGHT (owner, + // 2026-09-15). The clock used to stand at the margin — `in 2h`, `in + // now`, `holds` — beside a title that usually said the schedule too; + // the row's time is said ONCE, in its description, in the one form its + // kind of order has ([nextUpSaid]), under the cursor. lines = append(lines, homeLine{kind: homeLedger, project: pageStanding.word(), dir: item.ID, view: view, item: item, cell: &homeCell{panel: panelNext, - title: strings.TrimSpace(item.Words), right: standWhenClause(item, in.now), - // WHAT IT FOUND, under the cursor. The row's right-hand clause is - // the clock; this is what the order did the last time it woke - // ([nextUpSaid]), and it is drawn only for the row being read - // ([homeDescLines]). + title: strings.TrimSpace(item.Words), grows: in.desc, sub: nextUpSaidOn(in, view)}}) } return homePanelRows{lines: lines, more: len(views) - shown} @@ -41,7 +47,11 @@ func nextActive(in *homeGridInput) []StandingItemView { seen := map[string]bool{} for _, project := range in.world.Projects { for _, view := range in.items[project.Dir] { - if view.Item.Status != standing.StatusActive || seen[view.Item.ID] { + // AN ORDER STOPPED ON A PERSON IS NOT COMING UP, IT IS STOPPED. It is + // a row of `needs you` already, with its question on it, and a second + // row here would say one thing twice across the columns (owner, + // 2026-09-15). It comes back the moment the person answers. + if view.Item.Status != standing.StatusActive || view.Item.NeedsPerson != "" || seen[view.Item.ID] { continue } seen[view.Item.ID] = true @@ -51,7 +61,7 @@ func nextActive(in *homeGridInput) []StandingItemView { return views } -// nextUpSaidOn is the sentence the description column draws for a `next up` +// nextUpSaidOn is the sentence the description column draws for a `scheduled` // row, and nothing on a frame with no such column. func nextUpSaidOn(in *homeGridInput, view StandingItemView) string { if !in.desc { @@ -60,47 +70,114 @@ func nextUpSaidOn(in *homeGridInput, view StandingItemView) string { return nextUpSaid(view, in.now) } -// nextUpSaid is THE FINDING, NOT THE CLOCK. The margin already says when the -// order next wakes, and the title is the person's own words, which usually -// carry the cadence too (`sweep the repo every morning at nine…`); so a -// description that said `every morning at nine` was the third copy of one fact -// on one row (owner, 2026-09-15: "these seem redundant"). What the row has NOT -// said is what happened the last time the order woke, and that is what a -// person deciding whether to open it wants: a watch's last finding — the line -// it found, or that it found nothing, which for a watch is the most common -// finding and a real one ([standRollup] says the same) — and a reminder's or -// routine's last outcome. An order stopped on a person says so first, and one -// in the middle of a pass says what the pass is doing, in the card's own words. +// nextUpSaid is ONE SENTENCE PER KIND OF ORDER, and the only place a row says +// its time (owner, 2026-09-15: "communicate time information clearly and in a +// standardized way appropriate for each type — one way for each"). The row's +// title is the person's own words; this is the machine's account of the same +// order, in a fixed shape a person learns once: +// +// reminder · goes off tomorrow 9:00am +// routine · every morning at nine · next tomorrow 9:00am · last: done, two branches landed +// watch · every five minutes · last looked 3m ago · found: the last five runs are green +// rule · always +// +// The kind word comes first because the panel holds four kinds under one +// heading and nothing else on the row says which this is. A reminder has no +// cadence and no last time — it retires in the pass that fires it — so its +// sentence is the one moment it goes off. A routine says its cadence in the +// person's words, when the next one is, and what the last one came to. A watch +// says how often it looks, when it last looked, and what it found — `found +// nothing` being the commonest finding and a real one ([standRollup] says the +// same). A rule has no clock at all; it is its own words, or `holds`. An order +// in the middle of a pass says what the pass is doing instead of its clock. // -// THE EMPTINESS LAW REACHES IT: an order that has never woken has no finding -// and draws nothing, rather than a schedule the row has already said. +// THE EMPTINESS LAW REACHES EVERY CLAUSE: a routine that has never fired has no +// `last:`, a watch that has never looked has no `last looked`, and a clause +// with nothing behind it is not drawn as a blank. func nextUpSaid(view StandingItemView, now time.Time) string { item := view.Item - switch { - case item.NeedsPerson != "": - return tierYourCallWord + tierReasonSep + item.NeedsPerson - case view.Running: - return standRunWord(view, now) + if view.Running { + return rowClauses(nextUpKindWord(item), standRunWord(view, now)) } + words := strings.TrimSpace(item.When.Words) switch item.When.Kind { case standing.WhenHold: - return "" + if words == "" { + words = standHoldsWord + } + return rowClauses(nextUpKindWord(item), words) case standing.WhenProbe, standing.WhenFile, standing.WhenIdle: - if item.LastChecked.IsZero() { - return "" + looked, found := "", "" + if !item.LastChecked.IsZero() { + looked = nextUpLastLookedWord + sinceAt(item.LastChecked, now) + " ago" + found = nextUpFoundNothingWord + if line := switcherFirstLine(item.LastCheckLine); line != "" { + found = nextUpFoundWord + line + } } - if found := switcherFirstLine(item.LastCheckLine); found != "" { - return found + return rowClauses(nextUpKindWord(item), words, looked, found) + case standing.WhenAt: + return rowClauses(nextUpKindWord(item), nextUpGoesOffWord+homeClockAt(item.NextDue, now)) + } + next, last := "", "" + if !item.NextDue.IsZero() { + next = nextUpNextWord + homeClockAt(item.NextDue, now) + } + if !item.LastFired.IsZero() { + if outcome := switcherFirstLine(item.LastOutcome); outcome != "" { + last = nextUpLastWord + outcome } - return nextUpFoundNothingWord } - if item.LastFired.IsZero() { - return "" + return rowClauses(nextUpKindWord(item), words, next, last) +} + +// The fixed words of a `scheduled` row's sentence, spelled once. +const ( + nextUpGoesOffWord = "goes off " + nextUpNextWord = "next " + nextUpLastWord = "last: " + nextUpLastLookedWord = "last looked " + nextUpFoundWord = "found: " + nextUpFoundNothingWord = "found nothing" +) + +// nextUpKindWord is the one word for what kind of order a row is: the four the +// standing store distinguishes, in the person's own vocabulary for them. +func nextUpKindWord(item standing.Item) string { + switch item.When.Kind { + case standing.WhenAt: + return "reminder" + case standing.WhenEvery: + return "routine" + case standing.WhenProbe, standing.WhenFile, standing.WhenIdle: + return "watch" + case standing.WhenHold: + return "rule" } - return switcherFirstLine(item.LastOutcome) + return "" } -// nextUpFoundNothingWord is a watch that looked and found nothing, said as a -// sentence because it stands alone in the description column — the rollup's -// bare `nothing` reads as a gap there rather than as the finding it is. -const nextUpFoundNothingWord = "found nothing" +// homeClockAt is a moment said the way a person says one: `today 6:00pm`, +// `tomorrow 9:00am`, `mon 9:00am` inside the week, `21 sep 9:00am` beyond it, +// and `now` for a moment already here. The clock is the pulse's twelve-hour +// clock with its am or pm ([pulseClock] says why never a bare `9:41`). +func homeClockAt(at, now time.Time) string { + if at.IsZero() { + return "" + } + if !at.After(now) { + return "now" + } + clock := strings.ToLower(at.Format("3:04pm")) + day := func(t time.Time) time.Time { return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()) } + days := int(day(at).Sub(day(now)).Hours() / 24) + switch { + case days == 0: + return "today " + clock + case days == 1: + return "tomorrow " + clock + case days < 7: + return strings.ToLower(at.Format("Mon")) + " " + clock + } + return strings.ToLower(at.Format("2 Jan")) + " " + clock +} diff --git a/internal/tui3/homepanel_test.go b/internal/tui3/homepanel_test.go index fb10afe4f0..c2315f0d24 100644 --- a/internal/tui3/homepanel_test.go +++ b/internal/tui3/homepanel_test.go @@ -487,8 +487,15 @@ func TestNextUpIsSoonestFirstAndFoldsIntoStanding(t *testing.T) { frame := homeText(a) first, _ := homeRowOf(frame, "top movers before the open") second, _ := homeRowOf(frame, "water the plants") - if first < 0 || second != first+1 || !strings.Contains(strings.Split(frame, "\n")[first], " in 1h") { - t.Fatalf("next up is not soonest first with its clause:\n%s", frame) + // AND NOTHING AT THE ROW'S RIGHT: the time is said once, in the description + // (owner, 2026-09-15). It used to read `in 1h` at the margin. + if first < 0 || second != first+1 || strings.Contains(strings.Split(frame, "\n")[first], " in 1h") { + t.Fatalf("scheduled is not soonest first with nothing at its right:\n%s", frame) + } + for _, cell := range panelRows(a, panelNext) { + if cell.right != "" { + t.Fatalf("a scheduled row carries %q at its right, want nothing", cell.right) + } } if row, _ := homeRowOf(frame, "1 more"); row < 0 { t.Fatalf("the fourth order is not behind the fold:\n%s", frame) @@ -500,47 +507,59 @@ func TestNextUpIsSoonestFirstAndFoldsIntoStanding(t *testing.T) { } } -// A `next up` ROW'S DESCRIPTION IS THE FINDING, NOT THE CLOCK (owner, -// 2026-09-15). The margin says when the order next wakes — and for a watch that -// has looked, when it looked — and the description says what it found or what -// its last firing came to; an order that has never woken describes nothing, -// and the schedule is never said a second time. -func TestNextUpDescribesTheLastFindingAndNeverRepeatsTheClock(t *testing.T) { +// A `scheduled` ROW SAYS ITS TIME ONCE, IN ITS DESCRIPTION, IN THE ONE SHAPE +// ITS KIND HAS (owner, 2026-09-15): a reminder the moment it goes off, a routine +// its cadence, its next and its last outcome, a watch how often it looks, when +// it last looked and what it found, a rule its own words. Nothing at the +// margin, and an order that has never woken has no `last`. +func TestScheduledSaysEachKindsTimeOneWayInItsDescription(t *testing.T) { lab := newSwitchLab(t) a := lab.open(180, 45) dir := a.home.world.Projects[0].Dir + now := a.home.world.Read watch := StandingItemView{Item: standing.Item{ID: "w", Words: "tell me when CI goes red", Status: standing.StatusActive, - When: standing.When{Kind: standing.WhenProbe, Words: "every five minutes"}, NextDue: lab.now.Add(2 * time.Minute), - LastChecked: lab.now.Add(-3 * time.Minute), LastCheckLine: "the last five runs on master are green"}} + When: standing.When{Kind: standing.WhenProbe, Words: "every five minutes"}, NextDue: now.Add(2 * time.Minute), + LastChecked: now.Add(-3 * time.Minute), LastCheckLine: "the last five runs on master are green"}} quiet := StandingItemView{Item: standing.Item{ID: "q", Words: "watch the lockfile", Status: standing.StatusActive, - When: standing.When{Kind: standing.WhenFile, Words: "when go.sum changes"}, LastChecked: lab.now.Add(-time.Hour)}} + When: standing.When{Kind: standing.WhenFile, Words: "when go.sum changes"}, LastChecked: now.Add(-time.Hour)}} routine := StandingItemView{Item: standing.Item{ID: "r", Words: "sweep the repo every morning at nine", Status: standing.StatusActive, - When: standing.When{Kind: standing.WhenEvery, Words: "every morning at nine"}, NextDue: lab.now.Add(13*time.Hour + 30*time.Minute), - LastFired: lab.now.Add(-11 * time.Hour), LastOutcome: "done: two branches landed, both in internal/tui3"}} + When: standing.When{Kind: standing.WhenEvery, Words: "every morning at nine"}, NextDue: now.Add(26 * time.Hour), + LastFired: now.Add(-11 * time.Hour), LastOutcome: "done: two branches landed, both in internal/tui3"}} fresh := StandingItemView{Item: standing.Item{ID: "f", Words: "remind me at six to leave", Status: standing.StatusActive, - When: standing.When{Kind: standing.WhenAt, Words: "at 6 today"}, NextDue: lab.now.Add(4*time.Hour + 30*time.Minute)}} - a.home.items = map[string][]StandingItemView{dir: {watch, quiet, routine, fresh}} + When: standing.When{Kind: standing.WhenAt, Words: "at 6 today"}, NextDue: now.Add(4 * time.Hour)}} + rule := StandingItemView{Item: standing.Item{ID: "h", Words: "never change the public API without telling me", Status: standing.StatusActive, + When: standing.When{Kind: standing.WhenHold, Words: "always"}}} + stopped := StandingItemView{Item: standing.Item{ID: "s", Words: "keep main green", Status: standing.StatusActive, + When: standing.When{Kind: standing.WhenProbe, Words: "when CI goes red"}, NeedsPerson: "the fix touches migrations"}} + a.home.items = map[string][]StandingItemView{dir: {watch, quiet, routine, fresh, rule, stopped}} a.home.build() rows := map[string]*homeCell{} for _, cell := range panelRows(a, panelNext) { rows[cell.title] = cell } - want := []struct{ title, right, sub string }{ - {"tell me when CI goes red", "checked 3m ago", "the last five runs on master are green"}, - {"watch the lockfile", "checked 1h ago", nextUpFoundNothingWord}, - {"sweep the repo every morning at nine", "in 13h", "done: two branches landed, both in internal/tui3"}, - {"remind me at six to leave", "in 4h", ""}, + if _, drawn := rows["keep main green"]; drawn { + t.Fatalf("an order stopped on a person is drawn on scheduled as well as needs you:\n%s", homeText(a)) + } + want := []struct{ title, sub string }{ + {"tell me when CI goes red", "watch · every five minutes · last looked 3m ago · found: the last five runs on master are green"}, + {"watch the lockfile", "watch · when go.sum changes · last looked 1h ago · found nothing"}, + {"sweep the repo every morning at nine", "routine · every morning at nine · next " + homeClockAt(routine.Item.NextDue, now) + " · last: done: two branches landed, both in internal/tui3"}, + {"remind me at six to leave", "reminder · goes off " + homeClockAt(fresh.Item.NextDue, now)}, + {"never change the public API without telling me", "rule · always"}, } for _, w := range want { cell, ok := rows[w.title] if !ok { - t.Fatalf("next up does not draw %q:\n%s", w.title, homeText(a)) + t.Fatalf("scheduled does not draw %q:\n%s", w.title, homeText(a)) } - if cell.right != w.right || cell.sub != w.sub { - t.Fatalf("%q reads %q / %q, want the clock %q and the finding %q", w.title, cell.right, cell.sub, w.right, w.sub) - } - if strings.Contains(cell.sub, "every") || strings.Contains(cell.sub, "at 6") { - t.Fatalf("%q repeats its schedule in its description: %q", w.title, cell.sub) + if cell.right != "" || cell.sub != w.sub { + t.Fatalf("%q reads %q / %q, want nothing at the right and the sentence %q", w.title, cell.right, cell.sub, w.sub) } } + if !strings.HasPrefix(homeClockAt(fresh.Item.NextDue, now), "today ") && !strings.HasPrefix(homeClockAt(fresh.Item.NextDue, now), "tomorrow ") { + t.Fatalf("a moment four hours off reads %q, want today or tomorrow with a clock", homeClockAt(fresh.Item.NextDue, now)) + } + if got := homeClockAt(now.Add(10*24*time.Hour), now); !strings.Contains(got, " ") || strings.HasPrefix(got, "today") { + t.Fatalf("a moment ten days off reads %q, want a date and a clock", got) + } } From 6a3564b6867d479cb380ac4b20e03381c34bedb6 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Wed, 16 Sep 2026 09:26:40 -0400 Subject: [PATCH 21/23] tui3: the collapse coming back is a fold, not the find word MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fold on the grid became a toggle and a shut one is `N more` and nothing else, so this test was still waiting for `N more · type to find one` — a line the grid no longer draws. It reads the panel's own fold off the grid rather than the frame's words, because the words alone would also be satisfied by the `→ more` a legend can carry. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- internal/tui3/home_test.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/internal/tui3/home_test.go b/internal/tui3/home_test.go index 820e46e542..8a7dca4f88 100644 --- a/internal/tui3/home_test.go +++ b/internal/tui3/home_test.go @@ -1323,7 +1323,13 @@ func TestAMatchBehindTheCollapseIsFoundAnyway(t *testing.T) { t.Fatalf("the query could not see behind the collapse:\n%s", homeText(a)) } a.homeKey(key("ctrl+u")) - if !strings.Contains(homeText(a), "more · "+homeFindWord) { + // A SHUT FOLD IS `N more` AND NOTHING ELSE ([homeGridPanel.fold]), because + // the fold became a toggle rather than a door. So the collapse coming back + // is the panel standing on its own fold again, and the fold is read from the + // grid rather than from the text: the words alone would also match the + // `→ more` a legend can carry. It used to read `N more · type to find one`, + // and waiting for that clause waits for a line the grid no longer draws. + if fold := a.home.lines[homeFoldDoor(t, a, panelRecent)]; !strings.HasSuffix(fold.cell.title, " "+homeFoldMoreWord) { t.Fatalf("the collapse did not come back on an empty query:\n%s", homeText(a)) } if strings.Contains(homeText(a), "Buried Treasure") { From 006fbd6a3674e1c19185caca029835e6c551cd2e Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Wed, 16 Sep 2026 13:14:51 -0400 Subject: [PATCH 22/23] tui3: a scheduled row says its sentence at every width, a fold names no place, and the manual stops teaching the retired home MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of #1046 found three blockers and four should-fixes; this is all of them. A `scheduled` row drew its one sentence per kind only where the frame had a description column, so at 120 and 80 columns the panel was bare titles with no kind and no time on them once the clock left the margin. The sentence is drawn at every width now, as the line under the cursor's row where there is no middle column, the way a `where you were` row already draws its own. An open fold read `3 fewer · 40 more · tasks` while `enter` on it folded the panel rather than opening tasks — a door drawn on a wall. The fold counts and names no place; the panel's heading is the door (law 10). `homeFindWord` and the slot's `more` column go with it, and the tagged e2e fold test that waited for `type to find one` on a real terminal waits for `N more` instead. `enter` on an order made from home lands the standing place on that order through the margin's door, not at the top of the list. The compiled manual said which panels sit left and right, spelled the resting foot without `ctrl+o open folder` in three places, said the columns were fixed, and still described the `elsewhere · <workspace>` and `made from home — no conversation to open` refusals home no longer makes. DESIGN.md and the change entry stop promising an empty middle column and say the middle is the descriptions'. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- .../unreleased/1046-home-field-and-rail.md | 8 ++- docs/design/home-mission-control/DESIGN.md | 13 ++-- internal/e2e/tui_e2e_test.go | 23 ++++--- internal/e2e/tuiwords_test.go | 6 +- internal/manual/chat/commands.md | 19 ++--- internal/manual/chat/home.md | 44 +++++++----- internal/manual/chat/keys.md | 2 +- internal/manual/chat/places.md | 9 ++- internal/tui3/homeattention_test.go | 6 +- internal/tui3/homegrid.go | 69 ++++++++----------- internal/tui3/homegrid_test.go | 12 +++- internal/tui3/homepanel_needs.go | 7 +- internal/tui3/homepanel_next.go | 18 +++-- internal/tui3/homepanel_test.go | 28 ++++++++ internal/tui3/homestanding.go | 8 ++- internal/tui3/homestanding_test.go | 6 ++ 16 files changed, 165 insertions(+), 113 deletions(-) diff --git a/docs/changes/unreleased/1046-home-field-and-rail.md b/docs/changes/unreleased/1046-home-field-and-rail.md index 372d10bc5c..15a48404ef 100644 --- a/docs/changes/unreleased/1046-home-field-and-rail.md +++ b/docs/changes/unreleased/1046-home-field-and-rail.md @@ -6,17 +6,19 @@ surface: [chat, docs] invalidates: - "Home's law 2 was `act left, watch right`: the left column was the person's — `needs you`, `where you were`, `projects` — and the right column the machine's — `running`, `since you left`, `spend`, `next up`. No longer true, and this is not a tweak to the roster: a panel's column is no longer a fact about the panel at all. A panel WITH ROWS stands in the field, which is the columns left of the last one, filled from the top left corner down; a panel with NOTHING IN IT stands in the rail, the last column, flush with the right edge, as its heading and its whisper. So `running` is on the left while something is running and on the right while nothing is, and the side a panel is on now says whether it holds anything." - "Home's law 3 said `a panel keeps its column and its rank in it`. The column half is struck. The rank half stands and is what keeps the cost to one axis: inside the field and inside the rail the order is still the order table's, so two panels that both fill never swap places. Anything that remembers where a named panel is drawn is wrong; what can still be relied on is the order, and that `projects` and `spend` are PINNED to the top of the rail whatever they hold, with one blank row under them separating what lives there from what is only quiet today." - - "The three-column tier put `projects` and `spend` in a column of their own and spread the other five over the first two. No longer true: the rail is the last column at both the two- and three-column tiers, and THE FIELD FILLS RATHER THAN BALANCES — a frame whose panels fit in one column leaves the middle of a wide screen empty instead of spreading two short columns over it. The empty middle is the ruling and not a gap in it." + - "The three-column tier put `projects` and `spend` in a column of their own and spread the other five over the first two. No longer true: the rail is the last column at both the two- and three-column tiers, and THE FIELD FILLS RATHER THAN BALANCES — a frame whose panels fit in one column never spreads two short columns over a wide screen. The middle column at three is the descriptions' and holds no panel of headings." - "`←→` crossed to the NEIGHBOURING column and did nothing where that column had no row to stand on. No longer true: the arrow now steps over a column with nothing in it to the next one that has a row. Without it the rail would be out of the arrows' reach entirely on a wide quiet frame — `projects` and `spend` drawn on the screen with no key that walks to them." - - "`needs you` counted its live questions on its heading (`needs you · 2`) and its landings on the group line under them (`to check · 8`), with the clause `finished, nobody has checked it` at that line's right. No longer true: the heading is the two words alone, the group is called `unread`, and its line is that one word — no count, no clause. The rows under the words are the count. The one count left in the panel is the fold's, `3 unread · tasks` and `N older · tasks`, which stands for rows that are NOT on the screen. Anything waiting for the string `to check` on home — a test needle, a manual page — is looking for a word that is gone." + - "`needs you` counted its live questions on its heading (`needs you · 2`) and its landings on the group line under them (`to check · 8`), with the clause `finished, nobody has checked it` at that line's right. No longer true: the heading is the two words alone, the group is called `unread`, and its line is that one word — no count, no clause. The rows under the words are the count. The one count left in the panel is the fold's, `3 unread` or `N more`, which stands for rows that are NOT on the screen. Anything waiting for the string `to check` on home — a test needle, a manual page — is looking for a word that is gone." - "The `?` on a `needs you` row stood in the row's own lead cell, left of its title. On a frame wide enough for the description column it stands at the head of the row's description in that column, one space before the sentence, and the row's lead is blank; on a narrower frame it is where it was." - "A row's description — the question a conversation stopped on, the first sentence of where a conversation left off, what a standing order is about to do — was a second line UNDER the row, so a panel grew and shrank as the cursor walked. On a frame of three columns or more it is drawn in the middle column, level with its row, running down to the next row that has one; a permanent description (a `needs you` question) is always there and a cursor-only one (`where you were`, `next up`) appears beside the row the cursor is on. The middle column is no longer free for a third panel of headings; it is the descriptions' and nothing else stands in it. Two-column frames keep the second-line shape." - "A question raised from home — the consent card, a standing order's question — was said on the foot line and answered there. On a three-column frame it is drawn as a card in the middle column beside the row that raised it, WHOLE OR NOT AT ALL: a frame too short for the card falls back to the foot's one-line version, and the two are never on one frame together." - "`enter` on a `needs you` watch with no conversation behind it did nothing and said nothing. It opens the item on the standing place, and the row's legend says `enter on standing` so the key is not a surprise. `enter` on a landing in another project opens it — the guard that refused a bucket other than this window's is gone." - "Home's `say what you want done` line drew no cursor at rest. It carries the same cursor a conversation's box does." - "The foot under a field row said that row's own thing: `ctrl+x stop it` on a running task this window holds, `enter open where it was asked · ctrl+e pause · esc close` on a standing order, `enter opens the place this happened in · esc close` on a `since you left` line, and the resting sentence with `ctrl+o open folder` on a conversation. No longer true on the grid: EVERY row under a moving heading — `needs you` and `unread`, `where you were`, `running`, `since you left`, `next up` — rests on the one sentence, `type to search or start something new · ↑↓ pick · enter open · ctrl+o open folder · tab next place`, and `ctrl+o` is true on all of them because every such row belongs to a folder — an order's workspace, the conversation a landing ran in. `ctrl+e` and `ctrl+x` still work and are on the `alt+.` map; the foot no longer names them. The phone's list and the filtered list keep the rows' own sentences." + - "A `scheduled` row's description was drawn only on a frame with a description column (170 cells and up); below that the row was a bare title, with its clock gone from the margin as well. No longer true: the one sentence per kind is there at every width, as the line under the cursor's row where there is no middle column." + - "`enter` on a standing item that was made from home opened the standing place at its top. It now lands the cursor on that item." - "The right margin of a field row carried whatever fact its panel had: a landing's `3 files · 1d` beside another landing's bare `10h`, a `since you left` task's cost (`$0.42`) beside a made file's conversation name (`Pricing Site`), a conversation's project word as a tag beside its age (`infra 3h`) on rows from other folders and nothing on rows from this one, `here` on this window's own row, and a background job's project inside its title. No longer true: THE RIGHT MARGIN OF EVERY ROW OF THE FIELD IS A TIME — how long a question has waited, how long ago a landing landed, when a conversation was last spoken in, how long a task or job has run, when an order is due. Everything that is not a time is the row's description, in the middle column: a landing's file count opens its sentence (`3 files · built the flow`), a task's cost and a file's conversation are their rows' descriptions under the cursor, a conversation's project is the first clause of its description, a job's project is its description, and `here` opens the own row's description. The one exception is the word that says what `enter` will do — `another window`, `coming here`, `folder gone` — which still stands in the time's place, because it is the truth about the key. On a frame with no description column the own row says no `here` at all; its bold says it." - - "Every home panel folded its tail into a door: `N more · tasks` opened the tasks place, `N more · standing` opened standing, `needs you` said `N more · M older · tasks` for the landings that aged off it, and `where you were`'s `N more · type to find one` was not a stop at all because typing was its door. No longer true: EVERY FOLD IS ONE LINE, `N more`, AND IT IS A TOGGLE. `enter` (or a click) on it opens the panel — its cap is lifted, the other panels squeeze to their floors in law 5's order, and it takes the column — and the line reads `N fewer`; enter again folds it. One panel is open at a time. `needs you`'s aged landings are counted in its `N more` and come back when it is opened. An open panel taller than the column shows what fits and its fold names the place for the rest — `3 fewer · 40 more · tasks` — the one case a fold still names a place. The fold wears no mark (law 8). The foot on a fold says `enter shows the rest` / `enter folds them`. DESIGN.md law 9 is amended and says what it used to say." + - "Every home panel folded its tail into a door: `N more · tasks` opened the tasks place, `N more · standing` opened standing, `needs you` said `N more · M older · tasks` for the landings that aged off it, and `where you were`'s `N more · type to find one` was not a stop at all because typing was its door. No longer true: EVERY FOLD IS ONE LINE, `N more`, AND IT IS A TOGGLE. `enter` (or a click) on it opens the panel — its cap is lifted, the other panels squeeze to their floors in law 5's order, and it takes the column — and the line reads `N fewer`; enter again folds it. One panel is open at a time. `needs you`'s aged landings are counted in its `N more` and come back when it is opened. An open panel taller than the column shows what fits and its fold still counts the rest — `3 fewer · 40 more` — and names no place, because enter on it folds rather than opens; the panel's heading is the door to the place that holds them. The fold wears no mark (law 8). The foot on a fold says `enter shows the rest` / `enter folds them`. DESIGN.md law 9 is amended and says what it used to say." - "A `next up` row's description was the order's schedule in the person's words (`every five minutes`) beside a clock that said the same thing (`in now`), under a title that usually said it a third time. No longer true: THE DESCRIPTION IS THE FINDING — a watch's last check line or `found nothing`, a reminder's or routine's last outcome, `your call · why` for one stopped on a person, the pass's words for one mid-pass — and an order that has never woken describes nothing. And a watch that has looked says when it looked at the margin (`checked 3m ago`) rather than when it will look again; `standWhenClause` used to put `in now` there whenever a probe had a next due." - "The seventh panel was `next up · reminders & routines`, its rows carried their clock at the right (`in 20h`, `mon 8:30`, `checked 3m ago`, `holds`), and an order stopped on a person was drawn on it as well as on `needs you`. No longer true: the panel is `scheduled` — one word, no explainer, because it holds four kinds of order and the explainer named two — a row is the person's own words with NOTHING at its right, and its time is said once, in its description under the cursor, in the one sentence its kind has: `reminder · goes off tomorrow 9:00am`; `routine · every morning at nine · next tomorrow 9:00am · last: <outcome>`; `watch · every five minutes · last looked 3m ago · found: <line>` or `found nothing`; `rule · always`. Moments read `today 6:00pm`, `tomorrow 9:00am`, a weekday inside the week, a date beyond it, `now` once arrived. An order stopped on a person is off `scheduled` while it waits — it is a row of `needs you` — and paused, stopped and retired orders were never on it. The whisper names all four kinds. `standing` is not on the tab bar; `scheduled` is its summary and enter on a row opens it, which is law 10 as written." - "Home's resting foot was `type to search or start something new · ↑↓ pick · enter open · tab next place` on a quiet machine. It now carries the chord too — `… · enter open · ctrl+o open folder · tab next place`. The clause was always there on a machine whose right column had rows, because §6.6 refuses to leave a door invisible when `→` crosses columns rather than opening a row's verbs; what changed is that the rail always has rows now, so the condition is always met. The design's four keys still open the sentence and `esc` is still not on it." diff --git a/docs/design/home-mission-control/DESIGN.md b/docs/design/home-mission-control/DESIGN.md index 569dc32449..e5ed68c195 100644 --- a/docs/design/home-mission-control/DESIGN.md +++ b/docs/design/home-mission-control/DESIGN.md @@ -69,8 +69,10 @@ is the reading that produced the 2026-09-15 amendment. machine on every day; one blank row separates that pair from the panels that are in the rail only because they are quiet today. One column under 110 cells, two to 170, three past it — and the field FILLS rather than balances, - so a frame whose panels fit in one column leaves the middle of a wide screen - empty rather than spreading two short columns over it. + so a frame whose panels fit in one column never spreads two short columns + over a wide screen. The middle column at three is the DESCRIPTIONS': a + row's sentence stands beside it there, and a question raised from home + stands there as a card; no panel of headings is ever laid in it. *What this law was.* Until 2026-09-15 the column was a fact about the panel: the person's three on the left, the machine's four on the right. It was @@ -131,9 +133,10 @@ is the reading that produced the 2026-09-15 amendment. opens the panel: its cap is lifted, the other panels squeeze in law 5's order to their floors, and it takes the column; the fold reads `N fewer` and enter shuts it. One panel is open at a time. An open panel taller than the column - shows what fits and its fold names the place for the rest — `3 fewer · 40 - more · tasks` — so nothing is left without a door. The fold wears no mark - (law 8). A tall frame hands + shows what fits and its fold still counts the rest — `3 fewer · 40 more` — + and NAMES NO PLACE, because a line that named a place `enter` did not go to + would be a door drawn on a wall; the panel's heading is the door to the + place that holds them (law 10). The fold wears no mark (law 8). A tall frame hands its spare rows out — only once every panel has its natural height — in the squeeze's order reversed, one row each round: `needs you` and `running` to 8, `where you were` from 5 to 10, `since you left` to 8, `projects` to 8, diff --git a/internal/e2e/tui_e2e_test.go b/internal/e2e/tui_e2e_test.go index ea346dc79b..0412f252c5 100644 --- a/internal/e2e/tui_e2e_test.go +++ b/internal/e2e/tui_e2e_test.go @@ -43,6 +43,7 @@ import ( "os" "os/exec" "path/filepath" + "regexp" "strconv" "strings" "testing" @@ -1231,10 +1232,10 @@ func testHover(t *testing.T) { // // THE FOLD IS NOT A DOOR ANY MORE, AND THAT IS WHAT IS TESTED. The old list had // one fold at its foot — `▸ 15 more, quiet since 6d` — that `→` opened and `←` -// shut. Every panel folds inside itself now (DESIGN.md §1 law 9), and the fold -// under `where you were` is an instruction rather than a place: `N more · type to -// find one`. So the claim is the fold's words, a row it stands over being off the -// screen, and typing reaching that row anyway. +// shut. Every panel folds inside itself now (DESIGN.md §1 law 9) with one dim +// line, `N more`, that names no place: `enter` on it opens the panel. So the +// claim is the fold's words, a row it stands over being off the screen, and +// typing reaching that row anyway. func testFold(t *testing.T) { home := newHome(t, nil) // MORE CONVERSATIONS THAN THE PANEL'S BUDGET, WHICH IS WHAT MAKES A FOLD. @@ -1250,10 +1251,12 @@ func testFold(t *testing.T) { ws := newWorkspace(t, "foldws", false) r := start(t, "afe2e_fold", home, ws, tuiWide, 20) - screen := r.waitFor(25*time.Second, say(t, "homeFootWord"), say(t, "homeFindWord")) + screen := r.waitFor(25*time.Second, say(t, "homeFootWord"), say(t, "foldMoreWord")) t.Logf("`where you were` with a fold at its foot:\n%s", screen) - if fold := firstMatch(screen, say(t, "homeFindWord")); !strings.Contains(fold, say(t, "foldMoreWord")) { - t.Errorf("the fold does not count what it stands over: %q", fold) + // THE FOLD IS A COUNT AND THE WORD, AND NOTHING AFTER: a place word after it + // would be a door `enter` does not take. + if fold := firstMatch(screen, say(t, "foldMoreWord")); !foldCounts.MatchString(fold) { + t.Errorf("the fold does not read `N more` alone: %q", fold) } // THE OLDEST SEED IS BEHIND THE FOLD. The panel is the most recent first, and // the twentieth project is the quietest, so it is the one the fold stands over. @@ -1278,11 +1281,15 @@ func testFold(t *testing.T) { r.keys("Escape") time.Sleep(1500 * time.Millisecond) back := r.capture() - if !strings.Contains(back, say(t, "homeFindWord")) { + if !strings.Contains(back, say(t, "foldMoreWord")) { t.Errorf("esc did not put the panels back with their fold:\n%s", back) } } +// foldCounts is the shape of a shut fold as a person reads it off the frame: a +// count, the word, and nothing after it but the row's own padding. +var foldCounts = regexp.MustCompile(`\b\d+ more\s*$`) + // ── 8 ─────────────────────────────────────────────────────────────────────── // testNarrow is the phone-shaped window: sixty cells, where home has no room diff --git a/internal/e2e/tuiwords_test.go b/internal/e2e/tuiwords_test.go index 06d67830a8..7b5bff2860 100644 --- a/internal/e2e/tuiwords_test.go +++ b/internal/e2e/tuiwords_test.go @@ -207,13 +207,9 @@ var tuiWords = map[string]tuiWord{ "what arrives there and never announces that it is empty. It is short enough to stand on one line " + "at a hundred and twenty cells, which is why it is the whisper the suite waits for", }, - "homeFindWord": { - screen: "type to find one", - why: "the fold under `where you were` — an instruction rather than a door, because the rest are found by typing", - }, "foldMoreWord": { screen: " more", - why: "every panel's fold counts what it stands over — `14 more · type to find one`", + why: "every panel's fold counts what it stands over — `14 more` — and is a toggle, not a door", pkg: tui3Pkg, }, "barHomeWord": { diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index a8b799af1c..49548b9fae 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -1043,13 +1043,16 @@ There is no argument form. There are three other ways in: **`alt+1`**, home bein of the four places on the tab bar; **`space` twice** on an empty box; and **`tab`** from any other place. -**It is seven panels**, in one column under 110 cells, two from 110 and three from 170: -`needs you` (every question waiting on you, a digit answers the top one from anywhere), -`where you were` (this window's conversation, then the most recent, then -`N more · type to find one`) and `projects` (every folder, `enter` starts a chat there) on -the left; `running` (every task, job and firing watch), `since you left` (what landed while -you were away), `spend` (today and the fortnight) and `scheduled` (standing orders, soonest first) -on the right. An empty panel keeps its heading and one dim line naming what arrives there. +**It is seven panels**, in one column under 110 cells, two from 110 and three from 170, +always in one order: `needs you` (every question waiting on you, a digit answers the top one +from anywhere), `where you were` (this window's conversation, then the most recent, then a +`N more` fold that opens the rest), `projects` (every folder, `enter` starts a chat there), +`running` (every task, job and firing watch), `since you left` (what landed while you were +away), `spend` (today and the fortnight) and `scheduled` (standing orders, soonest first). +Which column a panel stands in follows what it holds: the panels with rows fill the **field** +at the left, and the **rail** at the right holds `projects` and `spend` at its top with the +quiet panels under them. An empty panel keeps its heading and one dim line naming what +arrives there. `↑`/`↓` walk a column, `←`/`→` cross columns, `enter` opens, `esc` closes back into the conversation you came from. **Typing does two things at once**: what you type is a new @@ -1058,7 +1061,7 @@ panels give way to the matches, with `start a new conversation: "…"` directly holding the cursor, so type-and-enter still starts a chat. **A line that starts with `/` is the third thing typing can be**: a command, run rather than sent (see *Typing a slash to see the command list*). The foot reads exactly -`type to search or start something new · ↑↓ pick · enter open · tab next place`. +`type to search or start something new · ↑↓ pick · enter open · ctrl+o open folder · tab next place`. Search matches conversation names, project names, task titles and **what tasks came to** — the one-sentence outcome — so `postgres` finds the chat whose work mentioned it, including diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index 570e1e9b69..947fc81cf5 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -85,9 +85,11 @@ always `needs you`, `where you were`, `projects`, `running`, `since you left`, ` One column under 110 cells, where every panel is in that one order and there is no rail; two columns from 110; three from 170, where the rail is the third and the field fills the -first. **A field that fits in one column leaves the middle of a wide screen empty** rather -than spreading two short columns over it — the point of the one busy column standing alone -at the left is that your eye has a single place to go. +first. **A field that fits in one column never spreads two short columns over a wide +screen** — the point of the one busy column standing alone at the left is that your eye has +a single place to go. From 170 cells the middle column is the **descriptions'**: the +sentence under a row is drawn beside it there, level with its row, and a question raised +from home stands there as a card. Nothing else stands in it. | Panel | What a row is | `enter` on a row | Dim line when it holds nothing | | --- | --- | --- | --- | @@ -184,7 +186,7 @@ watch that needs somebody, never age off home. 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 unread · tasks`, which opens the tasks place. +`8 unread`. `enter` on it opens the panel; the `needs you` heading opens the tasks place. ## Answer from home — a digit answers the question that is drawing its answers @@ -559,8 +561,10 @@ landings that aged off it. **The fold is a toggle.** Walk onto it and press `ent click it): the panel opens and takes the column — every row it has, with the other panels squeezed to their floors in the order below — and the line reads `N fewer`. `enter` again folds it. One panel is open at a time; opening a second folds the first. An open panel -taller than the window shows what fits and its line names where the rest are — -`3 fewer · 40 more · tasks` — so nothing is left without a door. The foot under a fold says +taller than the window shows what fits and its line still counts the rest — `3 fewer · 40 +more` — and names no place, because `enter` on it folds rather than opens. The way to +those rows is the panel's **heading**: `needs you`, `running` and `since you left` open the +tasks place, `scheduled` opens standing, `where you were` opens the search. The foot under a fold says which way it will go: `enter shows the rest`, then `enter folds them`. Opening lasts as long as the window; a relaunch starts folded. The fold wears no mark: home spends its two marks on the amber `?` and the one moving cell. @@ -759,7 +763,7 @@ straight back to it, for the same reason: it is alive, so there is nothing to re The foot line reads exactly: ``` -type to search or start something new · ↑↓ pick · enter open · tab next place +type to search or start something new · ↑↓ pick · enter open · ctrl+o open folder · tab next place ``` and it says what THAT row's keys do on a row that has its own — a `since you left` line, @@ -997,12 +1001,14 @@ exists — `enter` on a row of `projects`. launched in used to carry a dim `elsewhere`, and `enter` on one of its rows opened nothing. That is gone: `enter` opens any conversation on home, in any project. -**One refusal still uses the word.** Pressing `enter` on a **standing item** — a reminder, -a watch, a rule — asks to open the conversation that set it up, and where that conversation -belongs to another project this window cannot resume it. Home says -`elsewhere · <the item's workspace>` at the foot and opens nothing. An item that was set up -from home and never became a conversation says `made from home — no conversation to open` -instead. +**Home never says it on a standing item either, any more.** Pressing `enter` on a +**standing item** — a reminder, a watch, a rule — opens the conversation that set it up, +whichever project it belongs to; it used to refuse with `elsewhere · <the item's workspace>` +at the foot when that project was not this window's, and that refusal is gone. An item that +was set up from home and never became a conversation opens the **standing place with the +cursor on that item** — its own page is the honest answer to "show me this thing". The one +place the sentence `made from home — no conversation to open` is still said is the standing +place itself, when `enter` there asks for the conversation behind such an item. A second project is a second **conversation**, built the way the first one was, on its own workspace, with its own gate. A conversation still never moves between projects — though it @@ -1632,7 +1638,7 @@ it` rather than pretending. `enter` on the row **opens the conversation that asked for it** — that is the answer to "why did I get this?", whatever project it belongs to. Something you set up from home that -never became a conversation says `made from home — no conversation to open`. +never became a conversation opens the standing place with the cursor on it instead. ## Make a reminder think harder — how hard a standing item thinks, and ctrl+v on its row @@ -2066,7 +2072,9 @@ and **nothing at its right**: ``` **When it happens is said once, in the row's description under the cursor, and each kind -says it one way** — a fixed sentence you learn once, the kind word first: +says it one way** — a fixed sentence you learn once, the kind word first. It is there at +every width: beside the row in the middle column from 170 cells, and as the line under the +cursor's row on a narrower frame. - a reminder: `reminder · goes off tomorrow 9:00am` - a routine: `routine · every morning at nine · next tomorrow 9:00am · last: done, two branches landed` @@ -2240,5 +2248,7 @@ are said where they happen: in the conversation that met them, and on `/status`. **Show a preview card at rest.** The card is only beside a search, on a wide frame. -**Change a panel's place or turn one off.** The seven panels, their columns and their order -are fixed; only their heights move with the terminal. +**Change a panel's place or turn one off.** The seven panels and their order are fixed, and +which column each stands in is read off what it holds — a panel with rows is in the field at +the left, a quiet one in the rail at the right — so nothing about them is yours to set; only +their heights and their sides move with the terminal and the day. diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index 480ca1b67e..dab6002659 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -2064,7 +2064,7 @@ nowhere else**, and it says what the box is a draft *for*: that change them. `alt+w` walks the folder round the projects on this machine and `alt+o` opens the model list in home's own body; pressing either label does the same as its chord. The line under the box is the foot, and **at rest it is exactly** -`type to search or start something new · ↑↓ pick · enter open · tab next place`. `esc` +`type to search or start something new · ↑↓ pick · enter open · ctrl+o open folder · tab next place`. `esc` still closes home from anywhere; the resting foot does not spend a cell naming it, and `alt+.` draws the whole map when you want it. diff --git a/internal/manual/chat/places.md b/internal/manual/chat/places.md index 2cacdb0740..598aaec288 100644 --- a/internal/manual/chat/places.md +++ b/internal/manual/chat/places.md @@ -413,9 +413,12 @@ being held down — it only reports what arrived. ## home — what wants you, as seven panels The first place, and the one codeaf opens on. Everything on this machine, from every -project, as **seven panels** in one, two or three columns: `needs you`, `where you were` and -`projects` on the left; `running`, `since you left`, `spend` and `scheduled` on the right. An -empty panel keeps its heading and one dim line naming what arrives there. +project, as **seven panels** in one, two or three columns — `needs you`, `where you were`, +`projects`, `running`, `since you left`, `spend`, `scheduled`, always in that order. Which +column a panel stands in follows what it holds: every panel with rows is in the **field** at +the left, and the **rail** at the right holds `projects` and `spend` at its top and, under +them, whichever panels are quiet today. An 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 one row of `needs you` drawing its answers, and `enter` opens a row. `tab` is the way to the diff --git a/internal/tui3/homeattention_test.go b/internal/tui3/homeattention_test.go index a8d9131f20..a3d602515c 100644 --- a/internal/tui3/homeattention_test.go +++ b/internal/tui3/homeattention_test.go @@ -181,7 +181,7 @@ func TestAQuietMachineDrawsNothingForAStateItIsNotIn(t *testing.T) { for _, width := range []int{80, 120, homeCardMin, 200} { a.width = width text := homeText(a) - for _, claim := range []string{"what wants you first", "more, quiet", "more · " + homeFindWord} { + for _, claim := range []string{"what wants you first", "more, quiet", "more · type to find one"} { if strings.Contains(text, claim) { t.Fatalf("a %d-column quiet machine claimed %q:\n%s", width, claim, text) } @@ -235,7 +235,7 @@ func TestNoArrowLeavesTheCursorOnAHeadingOrABlank(t *testing.T) { // the quiet clause, the way to the rest — may not smuggle one back in a quieter // voice. func TestTheGridsOwnWordsNeverAnnounceAbsence(t *testing.T) { - for _, word := range []string{foldLine(15, "quiet since aug 21"), homeFindWord} { + for _, word := range []string{foldLine(15, "quiet since aug 21"), groupedInt(15) + " " + homeFoldMoreWord} { for _, banned := range []string{"nothing", "empty", " yet", "no "} { if strings.Contains(word, banned) { t.Fatalf("%q announces absence with %q", word, banned) @@ -521,7 +521,7 @@ func TestTypingTakesTheSwitcherAway(t *testing.T) { t.Fatal("typing into the box did not put home into a search") } text := homeText(a) - for _, gone := range []string{"where you were", "since you left", "more · " + homeFindWord} { + for _, gone := range []string{"where you were", "since you left", " " + homeFoldMoreWord} { if strings.Contains(text, gone) { t.Fatalf("a search kept the switcher's %q:\n%s", gone, text) } diff --git a/internal/tui3/homegrid.go b/internal/tui3/homegrid.go index d1115a3fcf..df0749ec2b 100644 --- a/internal/tui3/homegrid.go +++ b/internal/tui3/homegrid.go @@ -145,14 +145,11 @@ type homePanelSlot struct { // place is where the fold line opens; the zero page opens nothing. place page // head is where a press on the heading goes: THE PLACE THE HEADING NAMES - // (law 10, home is the summary of the tabs). It is its own column rather - // than the fold's, because `where you were` has a place to name — the typed - // search — and a fold that says `type to find one`, and the two are - // different answers. The zero page is a heading that names only its panel. + // (law 10, home is the summary of the tabs). It is the ONLY door to the + // rows a panel is not drawing, because the fold under them is a toggle and + // names no place ([homeGridPanel.fold]). The zero page is a heading that + // names only its panel. head page - // more is what the fold says after its count when it is not a place's - // word — the recent panel's fold is an instruction rather than a door. - more string } // homePanelOrder is THE ORDER TABLE (DESIGN §1 law 2 and law 5). Its row order @@ -176,22 +173,17 @@ type homePanelSlot struct { // clause a heading may carry after its word — see [homePanelSlot.explainer]. var homePanelOrder = []homePanelSlot{ {panel: needsPanel{homePanelBase{panelNeeds}}, word: "needs you", keep: 6, least: 4, rest: 4, most: 8, place: pageTasks, head: pageTasks}, - {panel: recentPanel{homePanelBase{panelRecent}}, word: "where you were", explainer: "enter reopens one", keep: 5, least: 4, rest: 5, most: 10, more: homeFindWord, head: pageSearch}, - {panel: projectsPanel{homePanelBase{panelProjects}}, word: "projects", explainer: "folders you've opened", pinned: true, keep: 4, least: 3, rest: 5, most: 8, more: homeFindWord}, + {panel: recentPanel{homePanelBase{panelRecent}}, word: "where you were", explainer: "enter reopens one", keep: 5, least: 4, rest: 5, most: 10, head: pageSearch}, + {panel: projectsPanel{homePanelBase{panelProjects}}, word: "projects", explainer: "folders you've opened", pinned: true, keep: 4, least: 3, rest: 5, most: 8}, {panel: runningPanel{homePanelBase{panelRunning}}, word: "running", explainer: "work you sent off", keep: 3, least: 4, rest: 4, most: 8, place: pageTasks, head: pageTasks}, {panel: leftPanel{homePanelBase{panelLeft}}, word: "since you left", keep: 2, least: 3, rest: 4, most: 8, place: pageTasks, head: pageTasks}, {panel: spendPanel{homePanelBase{panelSpend}}, word: "spend", pinned: true, keep: 1, least: 3, rest: 3, most: 3, place: pageSpend, head: pageSpend}, {panel: nextPanel{homePanelBase{panelNext}}, word: homeScheduledWord, keep: 0, least: 3, rest: 3, most: 5, place: pageStanding, head: pageStanding}, } -// homeFindWord is what a fold says where the rest are reached by typing rather -// than by a place: the box at the foot is a live query over every conversation -// and every project on the machine. -const homeFindWord = "type to find one" - // homeNeedsTaskFresh is how long a task's call stays a row of `needs you` after -// it landed. Past it the call is counted on the panel's fold as -// `N older · tasks` instead ([needsFresh]). +// it landed. Past it the call is counted in the panel's fold, `N more`, and +// comes back when the panel is opened ([needsFresh]). const homeNeedsTaskFresh = 48 * time.Hour // homeWhisper is what an empty panel says under its heading — THE COPY OF @@ -283,14 +275,6 @@ func homeColumnOf(slot homePanelSlot, cols int, empty bool) int { return 0 } -// foldWord is what the fold says after `N more · `, and "" for a bare count. -func (s homePanelSlot) foldWord() string { - if s.more != "" { - return s.more - } - return s.place.word() -} - // ── the reading every panel is taken from ────────────────────────────────── // homeGridInput is everything a panel may read, gathered from home's own caches @@ -1041,22 +1025,25 @@ func (p homeGridPanel) lines() []homeLine { // glyph down the column, one per panel, would be the thing that law exists to // refuse. The words say which way it goes: `more` opens, `fewer` shuts. // -// THE PLACE IS NAMED ONLY FOR WHAT STILL WILL NOT FIT. An open panel taller -// than the column shows what it can and its fold reads `3 fewer · 40 more · -// tasks` — the way back first, then where the rest are — because a fold that -// opened and still hid rows without saying where they were would leave them -// nowhere. It used to be the other way round: law 9 said the fold IS the door, -// `N more · tasks` opened the tasks place, and `where you were`'s `N more · type -// to find one` was not a stop at all because typing was its door; the owner -// found one line that opened somewhere and another that could not be stood on -// and asked for one thing that expands. +// AN OPEN PANEL TALLER THAN THE COLUMN STILL COUNTS WHAT IT CANNOT SHOW: its +// fold reads `3 fewer · 40 more` — the way back first, then how many are past +// the frame. THE FOLD NAMES NO PLACE, because `enter` on it toggles the panel +// and a line that named a place `enter` did not go to would be a door drawn on +// a wall (review of #1046). The way to the rest is the panel's HEADING, which +// opens the place that owns the panel (law 10): tasks for `needs you`, +// `running` and `since you left`, standing for `scheduled`, the search for +// `where you were`. It used to be the other way round: law 9 said the fold IS +// the door, `N more · tasks` opened the tasks place, and `where you were`'s `N +// more · type to find one` was not a stop at all because typing was its door; +// the owner found one line that opened somewhere and another that could not be +// stood on and asked for one thing that expands. func (p homeGridPanel) fold() homeLine { words := "" rest := p.hidden() + p.read.older if p.expanded { words = foldWords(true, p.opened(), "") if rest > 0 { - words += rowSep + groupedInt(rest) + " " + p.foldMoreWord() + rowSep + p.slot.foldWord() + words += rowSep + groupedInt(rest) + " " + p.foldMoreWord() } } else { // The group's word where the group's own line is not on the screen, @@ -1065,7 +1052,7 @@ func (p homeGridPanel) fold() homeLine { words = groupedInt(rest) + " " + p.foldMoreWord() } cell := &homeCell{kind: cellFold, panel: p.slot.panel.id(), title: words} - return homeLine{kind: homeFold, project: p.slot.foldWord(), dir: homeFoldKey, folded: !p.expanded, quiet: rest, cell: cell} + return homeLine{kind: homeFold, dir: homeFoldKey, folded: !p.expanded, quiet: rest, cell: cell} } // opened is how many rows opening the panel showed that its resting height @@ -1085,8 +1072,8 @@ 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 unread · tasks` is the whole of what a squeezed panel says -// about its landings — and `more` everywhere else. +// the screen — `8 unread` 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 { @@ -1095,10 +1082,10 @@ func (p homeGridPanel) foldMoreWord() string { 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 -// directory path contains. +// homeFoldKey is the identity a fold line carries in its dir, so the cursor on +// `3 more` is told apart from every row that carries a real directory there +// ([homeLine.sameRow] tells folds apart by their panel). It starts with a NUL, +// which no directory path contains. const homeFoldKey = "\x00fold" // gridOn reports that the resting column is the grid: not the phone's inbox, diff --git a/internal/tui3/homegrid_test.go b/internal/tui3/homegrid_test.go index 35650ee759..732ab9ed4a 100644 --- a/internal/tui3/homegrid_test.go +++ b/internal/tui3/homegrid_test.go @@ -921,7 +921,10 @@ func TestOpeningASecondFoldShutsTheFirst(t *testing.T) { // frame the opened `needs you` shows what fits and its fold reads `N fewer · M // more · tasks` — the way back first, then the place that holds the rest — so // nothing an open fold could not show is left with no door. -func TestAnOpenFoldOnAShortFrameStillNamesThePlaceForTheRest(t *testing.T) { +// An open panel taller than the column still counts what it cannot show, and +// names no place for it: `enter` on the fold toggles the panel, so a line that +// said `· tasks` would be a door `enter` does not take. The heading is that door. +func TestAnOpenFoldOnAShortFrameCountsTheRestAndNamesNoPlace(t *testing.T) { lab := newSwitchLab(t) for i := 0; i < 9; i++ { lab.presence("-beta", "cccc00000000000"+string(rune('1'+i)), session.PresenceWaiting, "question "+itoa(i), lab.now) @@ -930,7 +933,10 @@ func TestAnOpenFoldOnAShortFrameStillNamesThePlaceForTheRest(t *testing.T) { a.home.cursor = homeFoldDoor(t, a, panelNeeds) drive(t, a, key("enter")) fold := a.home.lines[homeFoldDoor(t, a, panelNeeds)].cell.title - if !strings.Contains(fold, " fewer · ") || !strings.HasSuffix(fold, " more · "+pageTasks.word()) { - t.Fatalf("the open fold on a short frame reads %q, want `N fewer · M more · tasks`", fold) + if !strings.Contains(fold, " fewer · ") || !strings.HasSuffix(fold, " "+homeFoldMoreWord) { + t.Fatalf("the open fold on a short frame reads %q, want `N fewer · M more`", fold) + } + if strings.Contains(fold, rowSep+pageTasks.word()) { + t.Fatalf("the open fold names a place enter does not go to: %q", fold) } } diff --git a/internal/tui3/homepanel_needs.go b/internal/tui3/homepanel_needs.go index 77e70244c3..9acf9719f7 100644 --- a/internal/tui3/homepanel_needs.go +++ b/internal/tui3/homepanel_needs.go @@ -90,7 +90,7 @@ func (needsPanel) rows(in *homeGridInput) homePanelRows { // landings (`to check · 8`); the owner cut both on 2026-09-15, because the // rows are right there under the words and a figure beside them is a second // thing to read that says nothing the rows do not. The fold still counts what - // it hides (`3 unread · tasks`), which is the one place a count stands for + // it hides (`3 unread`, `N more`), which is the one place a count stands for // rows that are NOT on the screen. The aged-out landings are counted on the // same fold ([needsFresh]). out.older = in.callsOlder @@ -106,8 +106,9 @@ func (needsPanel) rows(in *homeGridInput) homePanelRows { // A YOUR-CALL OLDER THAN [homeNeedsTaskFresh] IS HISTORY, NOT A QUESTION (owner, // 2026-09-10: a machine with twenty-four week-old landings drew twenty-four rows // over rows nobody was going to answer, and a live question arriving under them -// would have been the twenty-fifth). They stay one door away — the fold counts -// them into the tasks place, where every one of them still is. Only a task's +// would have been the twenty-fifth). They stay one press away — the fold counts +// them, opening the panel shows them, and the heading opens the tasks place, +// where every one of them still is. Only a task's // call ages: a conversation stopped on a question and a watch that needs // somebody are live, and are never aged out. A landing with no time on it is // not known to be old, and stays. diff --git a/internal/tui3/homepanel_next.go b/internal/tui3/homepanel_next.go index 4b9a216baa..809a0ad876 100644 --- a/internal/tui3/homepanel_next.go +++ b/internal/tui3/homepanel_next.go @@ -31,10 +31,17 @@ func (nextPanel) rows(in *homeGridInput) homePanelRows { // now`, `holds` — beside a title that usually said the schedule too; // the row's time is said ONCE, in its description, in the one form its // kind of order has ([nextUpSaid]), under the cursor. + // + // AND THE DESCRIPTION IS THERE AT EVERY WIDTH. With nothing at the + // right, the sentence is the only place the row says what kind of order + // it is and when it acts; a frame too narrow for the description column + // draws it as the second line under the cursor's row, the way a `where + // you were` row's description is drawn there, rather than showing a bare + // title with no time on it at all (review of #1046). lines = append(lines, homeLine{kind: homeLedger, project: pageStanding.word(), dir: item.ID, view: view, item: item, cell: &homeCell{panel: panelNext, title: strings.TrimSpace(item.Words), - grows: in.desc, sub: nextUpSaidOn(in, view)}}) + grows: true, sub: nextUpSaid(view, in.now)}}) } return homePanelRows{lines: lines, more: len(views) - shown} } @@ -61,15 +68,6 @@ func nextActive(in *homeGridInput) []StandingItemView { return views } -// nextUpSaidOn is the sentence the description column draws for a `scheduled` -// row, and nothing on a frame with no such column. -func nextUpSaidOn(in *homeGridInput, view StandingItemView) string { - if !in.desc { - return "" - } - return nextUpSaid(view, in.now) -} - // nextUpSaid is ONE SENTENCE PER KIND OF ORDER, and the only place a row says // its time (owner, 2026-09-15: "communicate time information clearly and in a // standardized way appropriate for each type — one way for each"). The row's diff --git a/internal/tui3/homepanel_test.go b/internal/tui3/homepanel_test.go index c2315f0d24..ec742caec6 100644 --- a/internal/tui3/homepanel_test.go +++ b/internal/tui3/homepanel_test.go @@ -562,4 +562,32 @@ func TestScheduledSaysEachKindsTimeOneWayInItsDescription(t *testing.T) { if got := homeClockAt(now.Add(10*24*time.Hour), now); !strings.Contains(got, " ") || strings.HasPrefix(got, "today") { t.Fatalf("a moment ten days off reads %q, want a date and a clock", got) } + // AND THE SENTENCE IS THERE AT EVERY WIDTH. With nothing at the right it is + // the only place the row says its kind and its time, so a frame too narrow + // for the description column draws it as the line under the cursor's row + // rather than a bare title (review of #1046). + for _, width := range []int{120, 80} { + // The column count reaches the panels through the draw, as it does on a + // terminal ([switchLab.open] states why). + a.width = width + homeText(a) + if homeDescOn(a.home.cols) { + t.Fatalf("%d columns has a description column; the test wants a frame without one", width) + } + // A narrow frame squeezes `scheduled` first (law 5), so the claim is + // about every row it still draws and not about how many those are. + sentence := map[string]string{} + for _, w := range want { + sentence[w.title] = w.sub + } + drawn := panelRows(a, panelNext) + if len(drawn) == 0 { + t.Fatalf("scheduled draws no row at all at %d columns:\n%s", width, homeText(a)) + } + for _, cell := range drawn { + if cell.sub != sentence[cell.title] || !cell.grows { + t.Fatalf("at %d columns %q reads %q (grows %v), want the sentence %q under the cursor", width, cell.title, cell.sub, cell.grows, sentence[cell.title]) + } + } + } } diff --git a/internal/tui3/homestanding.go b/internal/tui3/homestanding.go index 3e17f7d9f9..6205f949f5 100644 --- a/internal/tui3/homestanding.go +++ b/internal/tui3/homestanding.go @@ -744,10 +744,12 @@ func (a *app) homeItemEnter(line homeLine) tea.Cmd { // refusing. It was a dead key with a sentence beside it, which is the // worst of both: nothing happens AND the screen explains why on the row // a person is trying to leave. The item's own page is the honest answer - // to "show me this thing", and it is the same door a `next up` row takes - // ([app.homeLedgerEnter]). + // to "show me this thing", and the cursor lands ON THE ITEM rather than + // at the top of a list the person then has to find it in again — the + // margin's door ([app.openStandingAt]), because a press on a row should + // land on that row (review of #1046). a.closeHome() - return a.showPage(pageStanding) + return a.openStandingAt(line.item.ID) } // THE CAPABILITY IS ASKED THROUGH THE ONE PREDICATE, never off a single seam: // a door may answer the whole conversation ([Options.Open]) rather than the diff --git a/internal/tui3/homestanding_test.go b/internal/tui3/homestanding_test.go index 300e5de96a..467f5b75e3 100644 --- a/internal/tui3/homestanding_test.go +++ b/internal/tui3/homestanding_test.go @@ -571,6 +571,12 @@ func TestEnterOnAnItemOpensWhereItWasAsked(t *testing.T) { if !a.at(pageStanding) { t.Fatal("enter on an item made at home did not open the standing place") } + // AND THE CURSOR IS ON THE ITEM, not at the top of a list the person then + // has to find it in again: a press on a row lands on that row (review of + // #1046, [app.openStandingAt]). + if got, ok := a.orders.current(); !ok || got.ID != item.ID { + t.Fatalf("enter on an item made at home landed standing on %q (%v), want the item %q", got.ID, ok, item.ID) + } } // A WATCH ASKED FOR IN ANOTHER PROJECT STILL OPENS FROM HERE. It used to refuse From 74d9707ae53b94d691458c00d5967986e6adf2c9 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Wed, 16 Sep 2026 14:33:16 -0400 Subject: [PATCH 23/23] tui3: a question's door word is said under the cursor, not on every row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a frame without a description column every `needs you` question draws its second line, and every one of them ended in `enter` — a column of legends for a key only one row is about to take. The description column already drew the word only for the selected row; the second-line shape now agrees: the answering row keeps its chips, the cursor's row says its door, the rest say nothing at their right. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- .../unreleased/1046-home-field-and-rail.md | 1 + internal/manual/chat/home.md | 9 +-- internal/tui3/homegrid.go | 16 +++-- internal/tui3/homeneedsgroups_test.go | 60 +++++++++++++++++++ internal/tui3/homepanel_live_test.go | 9 +-- 5 files changed, 82 insertions(+), 13 deletions(-) diff --git a/docs/changes/unreleased/1046-home-field-and-rail.md b/docs/changes/unreleased/1046-home-field-and-rail.md index 15a48404ef..7cbc0386ef 100644 --- a/docs/changes/unreleased/1046-home-field-and-rail.md +++ b/docs/changes/unreleased/1046-home-field-and-rail.md @@ -17,6 +17,7 @@ invalidates: - "The foot under a field row said that row's own thing: `ctrl+x stop it` on a running task this window holds, `enter open where it was asked · ctrl+e pause · esc close` on a standing order, `enter opens the place this happened in · esc close` on a `since you left` line, and the resting sentence with `ctrl+o open folder` on a conversation. No longer true on the grid: EVERY row under a moving heading — `needs you` and `unread`, `where you were`, `running`, `since you left`, `next up` — rests on the one sentence, `type to search or start something new · ↑↓ pick · enter open · ctrl+o open folder · tab next place`, and `ctrl+o` is true on all of them because every such row belongs to a folder — an order's workspace, the conversation a landing ran in. `ctrl+e` and `ctrl+x` still work and are on the `alt+.` map; the foot no longer names them. The phone's list and the filtered list keep the rows' own sentences." - "A `scheduled` row's description was drawn only on a frame with a description column (170 cells and up); below that the row was a bare title, with its clock gone from the margin as well. No longer true: the one sentence per kind is there at every width, as the line under the cursor's row where there is no middle column." - "`enter` on a standing item that was made from home opened the standing place at its top. It now lands the cursor on that item." + - "Every `needs you` question that was not the answering row said `enter` at the right of its second line, so a narrow frame drew a column of `enter`s. Only the row under the cursor says its door word now; the rest say nothing at their right." - "The right margin of a field row carried whatever fact its panel had: a landing's `3 files · 1d` beside another landing's bare `10h`, a `since you left` task's cost (`$0.42`) beside a made file's conversation name (`Pricing Site`), a conversation's project word as a tag beside its age (`infra 3h`) on rows from other folders and nothing on rows from this one, `here` on this window's own row, and a background job's project inside its title. No longer true: THE RIGHT MARGIN OF EVERY ROW OF THE FIELD IS A TIME — how long a question has waited, how long ago a landing landed, when a conversation was last spoken in, how long a task or job has run, when an order is due. Everything that is not a time is the row's description, in the middle column: a landing's file count opens its sentence (`3 files · built the flow`), a task's cost and a file's conversation are their rows' descriptions under the cursor, a conversation's project is the first clause of its description, a job's project is its description, and `here` opens the own row's description. The one exception is the word that says what `enter` will do — `another window`, `coming here`, `folder gone` — which still stands in the time's place, because it is the truth about the key. On a frame with no description column the own row says no `here` at all; its bold says it." - "Every home panel folded its tail into a door: `N more · tasks` opened the tasks place, `N more · standing` opened standing, `needs you` said `N more · M older · tasks` for the landings that aged off it, and `where you were`'s `N more · type to find one` was not a stop at all because typing was its door. No longer true: EVERY FOLD IS ONE LINE, `N more`, AND IT IS A TOGGLE. `enter` (or a click) on it opens the panel — its cap is lifted, the other panels squeeze to their floors in law 5's order, and it takes the column — and the line reads `N fewer`; enter again folds it. One panel is open at a time. `needs you`'s aged landings are counted in its `N more` and come back when it is opened. An open panel taller than the column shows what fits and its fold still counts the rest — `3 fewer · 40 more` — and names no place, because enter on it folds rather than opens; the panel's heading is the door to the place that holds them. The fold wears no mark (law 8). The foot on a fold says `enter shows the rest` / `enter folds them`. DESIGN.md law 9 is amended and says what it used to say." - "A `next up` row's description was the order's schedule in the person's words (`every five minutes`) beside a clock that said the same thing (`in now`), under a title that usually said it a third time. No longer true: THE DESCRIPTION IS THE FINDING — a watch's last check line or `found nothing`, a reminder's or routine's last outcome, `your call · why` for one stopped on a person, the pass's words for one mid-pass — and an order that has never woken describes nothing. And a watch that has looked says when it looked at the margin (`checked 3m ago`) rather than when it will look again; `standWhenClause` used to put `in now` there whenever a probe had a next due." diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index 947fc81cf5..65cd134570 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -192,8 +192,9 @@ as an ordinary line once it has been answered or has aged out of the group. **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 words are that question's own and nothing here is put +wherever the cursor is. No other row draws answers, so one `1` is never on the screen twice +with two meanings; the row the cursor is on says `enter` instead, and the rest say nothing at +their right. 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 @@ -1502,8 +1503,8 @@ see the next section. **You can answer it here, without opening the window it is in.** The top `needs you` row that has answers draws them on its own second line, out at the right — and **pressing the -digit answers it, from anywhere on home**. Every row under it says `enter`, which opens the -conversation to answer it there. +digit answers it, from anywhere on home**. The row the cursor is on says `enter`, which opens +the conversation to answer it there; the other rows say nothing at their right. The chips are the ones the question has: diff --git a/internal/tui3/homegrid.go b/internal/tui3/homegrid.go index df0749ec2b..43c2241084 100644 --- a/internal/tui3/homegrid.go +++ b/internal/tui3/homegrid.go @@ -1517,12 +1517,15 @@ func (a *app) homeRowOffer(line homeLine) string { // 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. +// this window has already answered it, and the row's own door word — `enter`, +// `enter on standing` — on the row the cursor is standing on. // -// 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). +// THE DOOR WORD IS SAID ONCE, UNDER THE CURSOR. It is a key legend, and a legend +// is for the key a person is about to press; drawn on every question at once it +// was a column of `enter`s that told nobody anything (owner, 2026-09-16, seen on +// a narrow frame where the second line is under every question rather than +// beside the cursor's). The description column already drew it only for the +// selected row ([app.homeDescNote]); this makes the second-line shape agree. func (a *app) homeRowAnswers(line homeLine, at int) string { if line.cell == nil { return "" @@ -1532,6 +1535,9 @@ func (a *app) homeRowAnswers(line homeLine, at int) string { return words } } + if at != a.home.cursor { + return "" + } return line.cell.subRight } diff --git a/internal/tui3/homeneedsgroups_test.go b/internal/tui3/homeneedsgroups_test.go index 3d9f7e782d..e98cf7ebdb 100644 --- a/internal/tui3/homeneedsgroups_test.go +++ b/internal/tui3/homeneedsgroups_test.go @@ -114,6 +114,66 @@ func TestTheAnswerChipsAreOnOneRowOfTheFrame(t *testing.T) { } } +// THE DOOR WORD IS UNDER THE CURSOR AND NOWHERE ELSE. On a two-column frame +// every question draws its second line, and every one of them used to end in +// `enter` — a column of legends for a key only one row is about to take (owner, +// 2026-09-16). The answering row keeps its chips; the cursor's row says its door; +// the rest say nothing at their right. +func TestOnlyTheCursorsQuestionSaysEnter(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.openAt(120, 40) + if homeDescOn(a.home.cols) { + t.Fatal("120 columns has a description column; the test wants the second-line shape") + } + var questions []int + landing := -1 + for at, line := range a.home.lines { + if line.kind == homeSession && line.cell != nil && line.cell.panel == panelNeeds && line.cell.subRight != "" { + questions = append(questions, at) + } + if line.task != nil && line.task.ID == "4" { + landing = at + } + } + if len(questions) != 2 || landing < 0 { + t.Fatalf("want two question rows and a landing, found %d and %d:\n%s", len(questions), landing, homeText(a)) + } + // Cursor on the second question: the cursor outranks the top row (law 7), + // so the second draws its chips and the first — no longer the answering row, + // not under the cursor — says nothing at its right where it used to say + // `enter`. + a.home.cursor = questions[1] + if got := a.homeRowAnswers(a.home.lines[questions[1]], questions[1]); got == "" || got == needsOpenWord { + t.Fatalf("the cursor's question reads %q, want its chips", got) + } + if got := a.homeRowAnswers(a.home.lines[questions[0]], questions[0]); got != "" { + t.Fatalf("the question above the cursor reads %q, want nothing at its right", got) + } + frame := homeText(a) + if under := homeLineAfter(frame, "Pricing Site"); strings.Contains(under, needsOpenWord) { + t.Fatalf("the question above the cursor says a door word:\n%s", frame) + } + // Cursor on the landing: the chips follow it, and NEITHER question says + // `enter` — the whole column of legends is gone. + a.home.cursor = landing + for _, at := range questions { + if got := a.homeRowAnswers(a.home.lines[at], at); got != "" { + t.Fatalf("a question the cursor is not on reads %q, want nothing at its right", got) + } + } + frame = homeText(a) + for _, title := range []string{"Pricing Site", "Prime Sieve"} { + if under := homeLineAfter(frame, title); strings.Contains(under, needsOpenWord) { + t.Fatalf("%q says a door word with the cursor elsewhere:\n%s", title, 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) { diff --git a/internal/tui3/homepanel_live_test.go b/internal/tui3/homepanel_live_test.go index 6bae54a3aa..b5493fe45c 100644 --- a/internal/tui3/homepanel_live_test.go +++ b/internal/tui3/homepanel_live_test.go @@ -92,8 +92,9 @@ func panelRows(a *app, panel homePanelID) []*homeCell { // ── needs you ─────────────────────────────────────────────────────────────── // TWO QUESTIONS, THE LONGEST WAIT FIRST, AND THE ANSWERS ON THE TOP ONE ONLY: the -// consent line is the gate's own sentence, and the row below says `enter` -// because a second `1` on screen would be a guess. +// consent line is the gate's own sentence, and the row below draws no second +// `1` because it would be a guess — and no `enter` either, because the door +// word is said under the cursor alone ([app.homeRowAnswers]). func TestNeedsYouOrdersTheWaitsAndDrawsAnswersOnTheTopRowOnly(t *testing.T) { l := newLiveLab(t) l.live("-beta", "bbbb000000000001", session.SessionPresence{State: session.PresenceWaiting, @@ -112,8 +113,8 @@ func TestNeedsYouOrdersTheWaitsAndDrawsAnswersOnTheTopRowOnly(t *testing.T) { if under := homeLineAfter(frame, "Pricing Site"); !strings.Contains(under, "1 allow once 2 always 3 deny") { t.Fatalf("the top row does not draw its answers:\n%s", frame) } - if under := homeLineAfter(frame, "Prime Sieve"); strings.Contains(under, "allow once") || !strings.Contains(under, "enter") { - t.Fatalf("the second row drew a second set of answers:\n%s", frame) + if under := homeLineAfter(frame, "Prime Sieve"); strings.Contains(under, "allow once") || strings.Contains(under, "enter") { + t.Fatalf("the second row drew answers or a door word the cursor is not on:\n%s", frame) } // AND THE HEADING IS THE WORD ALONE. It used to count the questions // (`needs you · 2`); the rows are under it (owner, 2026-09-15).