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..e3d98ff8a3 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 } @@ -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/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/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 new file mode 100644 index 0000000000..7cbc0386ef --- /dev/null +++ b/docs/changes/unreleased/1046-home-field-and-rail.md @@ -0,0 +1,52 @@ +--- +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 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` 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." + - "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." + - "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: `; `watch · every five minutes · last looked 3m ago · found: ` 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." +--- + +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. + +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 4b04ea274f..e5ed68c195 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,76 @@ 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 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 … 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 + 70 more working · checking + npm run dev · pricing-site up 3h + since you left · 12h + 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 + ─────────────────────────────────────────────────────────────────────────────────────── › 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 `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. + ### 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 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 + 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. @@ -57,13 +98,13 @@ own keys, each a door to the place that owns it. The chat list is one panel. 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 `N more · `. + 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 `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. @@ -74,7 +115,7 @@ own keys, each a door to the place that owns it. The chat list is one panel. 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 @@ -82,19 +123,28 @@ own keys, each a door to the place that owns it. The chat list is one panel. `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 - `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 · ` 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 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, `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 @@ -132,7 +182,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/.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 @@ -234,7 +284,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 @@ -262,8 +312,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 @@ -280,3 +332,17 @@ Spark, branches `home/mc-`, 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/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 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 d3352fe19a..7b5bff2860 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", @@ -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/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/commands.md b/internal/manual/chat/commands.md index 4eaffbeb86..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 `next up` (reminders, 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 f74cd1a681..65cd134570 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -11,33 +11,53 @@ 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 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 + unread opus 63% · 3 chats and 1 task today + tier-B subs 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 + 16 more + 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 + 70 more + + running · 2 + ⠋ Generate and Display First 200 Primes 4m + bash · 12s · 2 of 5 + npm run dev · pricing-site · a background job - projects next up - ~/codeaf 12 chats · 1 running master the 6am repo watch in 20h - ~/pricing-site 5 chats + since you left · 12h + Spark Fleet Ssh Audit · 2 hosts up, 1 not + made apartments-minto-street.md + + scheduled + the 6am repo watch ``` +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 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 `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, +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. @@ -48,20 +68,38 @@ 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`, +`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 +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 | | --- | --- | --- | --- | -| `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` | | `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 @@ -71,8 +109,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: @@ -82,9 +122,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 @@ -93,18 +133,18 @@ 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. -## 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 - tier-B subs 3 files · 1d + unread + tier-B subs 1d package release radar 1d ``` @@ -112,15 +152,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, @@ -135,31 +178,32 @@ 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. -**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`. `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 **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 -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 @@ -182,7 +226,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. @@ -199,7 +243,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: @@ -207,7 +252,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; @@ -224,8 +269,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. @@ -247,14 +293,16 @@ 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 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; `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. @@ -279,8 +327,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 @@ -290,12 +339,14 @@ spinner. In screen-reader (linear) mode nothing turns at all. ``` 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 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 `, 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 `, 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`. @@ -308,26 +359,29 @@ 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`: -- **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`; -- then `N more · type to find one` — typing finds any conversation on the machine. + 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` — `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. -**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 @@ -355,8 +409,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. @@ -381,9 +435,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 @@ -401,9 +454,9 @@ 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, 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 @@ -421,7 +474,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 @@ -437,10 +490,14 @@ 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 -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. +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 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. @@ -497,20 +554,28 @@ 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 - -**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. +## 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 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 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. **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 @@ -523,14 +588,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`, `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. 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 @@ -579,14 +645,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 @@ -679,7 +745,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. @@ -698,7 +764,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, @@ -936,12 +1002,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 · ` 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 · ` +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 @@ -1435,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: @@ -1530,11 +1598,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 @@ -1571,7 +1639,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 @@ -1772,7 +1840,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, @@ -1828,10 +1896,11 @@ 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 — -`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. +**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 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 @@ -1868,8 +1937,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 @@ -1986,31 +2055,53 @@ 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, at the foot of the right column**: 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`: +**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 + 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 ``` -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. +**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. 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` +- 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 -**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: ``` @@ -2158,5 +2249,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/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 9d0fcb0c77..dab6002659 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -2004,8 +2004,8 @@ 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 | -| `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 | +| 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 `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 | @@ -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 @@ -2064,13 +2064,17 @@ 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. -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 `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 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` @@ -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+` | **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+` | diff --git a/internal/manual/chat/places.md b/internal/manual/chat/places.md index 8a33ce176e..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 `next up` 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/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 5672e4942b..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 @@ -2262,6 +2272,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) { @@ -2312,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). @@ -2642,12 +2665,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 } @@ -3097,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 @@ -5355,6 +5393,23 @@ 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 == 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: @@ -5456,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/home_test.go b/internal/tui3/home_test.go index 8dd6d1eb49..8a7dca4f88 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). // @@ -922,8 +962,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 +989,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) } @@ -1270,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") { diff --git a/internal/tui3/homeattention_test.go b/internal/tui3/homeattention_test.go index d08f8605ab..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) @@ -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 @@ -495,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/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/homeband_keys.go b/internal/tui3/homeband_keys.go index fdf66215e7..5ea88e02cb 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,18 @@ 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. + 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/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/homebands_ambientb_test.go b/internal/tui3/homebands_ambientb_test.go index 222a996493..c565d43deb 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 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.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") { t.Fatalf("item keys = %q", got) } 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 1819648ca9..fb9d8a54d0 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, columns[0]) + } rows := make([]placeRow, room) for y := range rows { rows[y] = homeGridZip(columns, y, xs) @@ -63,6 +66,130 @@ func (a *app) homeGridRows(width, room int, pal palette) []placeRow { return rows } +// 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 +// 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. +// +// 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, field []homeCellLine) []homeCellLine { + h := &a.home + if room <= 0 || width <= homeGridLead { + return nil + } + // 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 + 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 + } + 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 + } + 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 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-homeDescLeadCells) + answers := "" + if selected { + answers = strings.TrimSpace(a.homeRowAnswers(line, at)) + } + if line.cell.alwaysSaid() { + return []string{a.homeDescLead(line.cell, pal) + switcherSides(room, said, answers, pal.dim, pal.muted)} + } + var out []string + for _, words := range wrap(said, room) { + out = append(out, homeDescLeadBlank+pal.dim(words)) + } + if answers != "" { + out = append(out, "", homeDescLeadBlank+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 { @@ -146,6 +273,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. @@ -247,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 @@ -282,7 +417,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) @@ -300,6 +437,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)) + " " @@ -454,3 +601,97 @@ 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 { + return nil + } + // 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 < min(room, len(field)); 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 + } + } + } + said := a.homeAskRows(max(1, width-homeGridLead)) + // 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))) + 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 +} + +// 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)) + strings.Repeat(" ", homeDescLeadCells-1) + } + 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 997670d1f4..b3a2c21628 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.homeAskFitsColumn() { + return + } if a.homeTierNow() != homeTierList && !a.home.gridOn() { return } diff --git a/internal/tui3/homegrid.go b/internal/tui3/homegrid.go index ab34b58bef..43c2241084 100644 --- a/internal/tui3/homegrid.go +++ b/internal/tui3/homegrid.go @@ -1,6 +1,7 @@ package tui3 import ( + "math" "sort" "strings" "time" @@ -123,13 +124,15 @@ 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 - // 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 @@ -142,22 +145,24 @@ 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 -// 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,29 +172,20 @@ 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, 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 -// 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. // @@ -208,22 +204,36 @@ 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 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 { @@ -234,23 +244,35 @@ 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 - } - return 0 -} +// 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) } -// 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 +// 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 s.place.word() + return 0 } // ── the reading every panel is taken from ────────────────────────────────── @@ -265,7 +287,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 @@ -292,6 +314,17 @@ 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 + // ([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 @@ -302,8 +335,17 @@ 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, - world: world, items: h.items, + 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} } @@ -352,12 +394,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. @@ -371,8 +413,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 @@ -463,6 +504,24 @@ func (l homeLine) height() int { return 1 } +// alwaysSaid reports a row whose sentence the description column draws WHETHER +// OR NOT the row is selected. +// +// `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 `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 { + return c != nil && c.panel == panelNeeds && !c.grows && strings.TrimSpace(c.sub) != "" +} + // ── the layout ───────────────────────────────────────────────────────────── // homeGrid is the shape the last build settled: how many columns, and which @@ -483,11 +542,30 @@ 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 + // 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. @@ -498,8 +576,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. @@ -517,6 +596,13 @@ 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 — unless it is a row that keeps its own + // ([homeCell.keepsSub]). + 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 @@ -531,7 +617,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() { @@ -569,6 +655,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 } @@ -587,10 +685,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 } @@ -691,7 +797,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 { @@ -700,26 +806,110 @@ 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 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. +// +// 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) for _, slot := range homePanelOrder { read := slot.panel.rows(in) - at := slot.column(cols) - 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()) if p.empty() { p.whisper = homeWhisperLines(slot.panel.whisper(), widths[at]) } 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 } +// 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 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 +// 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 +954,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 { @@ -806,7 +1001,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]...) @@ -817,38 +1012,68 @@ func (p homeGridPanel) lines() []homeLine { return out } -// fold is `N more · ` (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. +// +// 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 { - 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() + } + } 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, 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" // foldMoreWord is what the fold calls the rows it is standing for: the GROUP's // name where every one of them is the group's and the group's own line is not on -// the screen — `8 to check · tasks` is the whole of what a squeezed panel says -// about its landings — and `more` everywhere else. +// 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 { @@ -857,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, @@ -963,24 +1188,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. @@ -1019,47 +1251,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 "" } @@ -1269,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 "" @@ -1284,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/homegrid_test.go b/internal/tui3/homegrid_test.go index 1caeac1162..732ab9ed4a 100644 --- a/internal/tui3/homegrid_test.go +++ b/internal/tui3/homegrid_test.go @@ -2,10 +2,14 @@ package tui3 import ( "fmt" + "path/filepath" "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" ) @@ -70,36 +74,342 @@ 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 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 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") - 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", 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) + } + } + // 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 == 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) { + 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.alwaysSaid() + }) + 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. 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 in the description column:\n%s", said, frame) + } + 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) + 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. + // (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(ansi.Truncate(lines[title+1], descX, ""), firstWordsOf(said)) { + t.Fatalf("the row still draws its own second line:\n%s", frame) + } +} + +// `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 + 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.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) + 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 != row { + t.Fatalf("the question is on row %d and its row is on %d:\n%s", row, title, 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) + } +} + +// 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) + } +} + +// 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) + } +} + +// 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 { + 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, @@ -180,17 +490,25 @@ 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 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 +628,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 +693,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") } } @@ -445,3 +771,172 @@ 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) + } +} + +// 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. +// 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) + } + 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, " "+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/homeneedsgroups_test.go b/internal/tui3/homeneedsgroups_test.go index c63dc27a95..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) { @@ -137,7 +197,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,10 +243,14 @@ 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`, 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) } } @@ -238,14 +302,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..8b0a5f8370 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 @@ -35,9 +35,9 @@ 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) + out := homePanelCut(in, panelLeft, lines) if len(lines) > 0 { out.said = sinceAt(in.seen, in.now) } @@ -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} } @@ -56,7 +63,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 { @@ -88,7 +95,7 @@ const ( ) // ledgerLanded is a line per piece of work that landed since the look stamp: -// `