From 3c4507530561230b086cf8c4b1534428053d73e5 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 12 Sep 2026 09:50:28 -0400 Subject: [PATCH 1/9] home: the box is three rows tall the moment you type in it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Home's foot box grew to fit its draft and no further, so the commonest state — a question one sentence long — drew a single row between the rule above it and the hint below it. The owner's reading was that it is too thin to notice and does not read as somewhere to type, and the cause is that one row of text bounded by two rows of chrome has no mass of its own. homeDraftFloor is three, and it is the same three as homeDraftRows, which makes the typing area a block of ONE HEIGHT: it does not grow under the hand as a sentence wraps, and the list above it does not step down a row when it does. A box that changed height while somebody typed into it was the other half of the same complaint, and both are answered by one number rather than two that would have to be kept apart. The rows are padded BELOW the draft, never above it. pages.go derives the caret's row by subtracting this block's height from the rows placed, so a pad at the bottom moves both by the same amount and the caret stays on the letter it is on. Past three rows the window still scrolls under the caret, so a paste cannot push the list off the screen. An empty box is still one row drawing the place's own dim sentence, which is the list-first shape this screen is built on and the emptiness law besides. Verified by driving bin/aforge against a seeded demo home at 150x40: a one-line draft draws three rows, a wrapping draft draws two rows of text and one pad, and tmux reports the caret at the last character of the second row. Co-Authored-By: Claude Opus 5 --- .../unreleased/997-home-input-three-rows.md | 15 +++++++++ internal/manual/chat/home.md | 16 +++++++--- internal/tui3/home.go | 32 +++++++++++++++++++ internal/tui3/pages.go | 9 ++++++ 4 files changed, 67 insertions(+), 5 deletions(-) create mode 100644 docs/changes/unreleased/997-home-input-three-rows.md diff --git a/docs/changes/unreleased/997-home-input-three-rows.md b/docs/changes/unreleased/997-home-input-three-rows.md new file mode 100644 index 0000000000..75c5cb1b5a --- /dev/null +++ b/docs/changes/unreleased/997-home-input-three-rows.md @@ -0,0 +1,15 @@ +--- +kind: changed +title: Home's box is three rows tall the moment you type in it +pr: 997 +surface: [chat] +invalidates: + - "Home's foot box grew to fit its draft and no further, so the commonest state — a question one sentence long — drew a SINGLE row between the rule above it and the hint below it. The owner's reading was that it is too thin to notice and does not look like somewhere to type, and the cause is that one row of text bounded by two rows of chrome has no mass of its own. It now stands three rows high from the first character (`homeDraftFloor`), padded below the draft so the first line typed stays on the first row." + - "The box no longer changes height while somebody types. The floor and the ceiling are both three, so a sentence that wraps to a second row does not grow the box and does not step the list above it down a row. Anything that remembers home's box as growing with its content is wrong; past three rows the window scrolls under the caret exactly as it did." + - "An empty box is still ONE row, drawing the place's own dim sentence. That is unchanged and deliberate: this screen is a list first, and three blank rows held open for a box nobody is typing in would be the cockpit it is not." +--- + +The caret arithmetic in `pages.go` is untouched. It derives the caret's row by +subtracting the block's height from the rows placed, so padding at the bottom +moves both by the same amount — verified by driving the real binary against the +demo home, where the caret lands on the last character of a two-row draft. diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index e2c2c73f90..1ec4813720 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -706,11 +706,17 @@ a fold door, the action row — always with `alt+. map · tab next place` before ## Typing a long question on home — does the box wrap, and where does a paste go -**The box wraps.** Home's foot box is drawn by the same editor as the chat's message box: -a sentence longer than the frame wraps onto continuation rows — up to three — and past -that the window scrolls with the caret, marked with `…` where the `›` was. Nothing you -type is ever truncated out of view. There is no key to open a new line here (that is the -chat box's `ctrl+j`). +**The box wraps, and it is three rows tall the whole time.** Home's foot box is drawn by +the same editor as the chat's message box: a sentence longer than the frame wraps onto +continuation rows. The moment you type anything the box stands **three rows** high and +stays there — it does not grow under your hand as a sentence wraps, and the list above it +does not step down a row when it does. Past three rows the window scrolls with the caret, +marked with `…` where the `›` was. Nothing you type is ever truncated out of view. There +is no key to open a new line here (that is the chat box's `ctrl+j`). + +**An empty box is one row.** With nothing typed it draws the dim sentence about what this +screen does and nothing else, because a box nobody is typing in has nothing to show. The +three rows arrive with the first character. **`alt+enter` is a task and not `ask here`.** On home and on every other place the chord opens the **composer layer**, where the three facts a task needs are settled — where, on diff --git a/internal/tui3/home.go b/internal/tui3/home.go index f8578221e7..8909045751 100644 --- a/internal/tui3/home.go +++ b/internal/tui3/home.go @@ -254,6 +254,38 @@ const ( // the caret exactly as the chat box's does (input.go's draftBlock). const homeDraftRows = 3 +// homeDraftFloor is how many rows home's foot box occupies ONCE SOMETHING IS +// TYPED IN IT, however little that is. +// +// A ONE-ROW BOX READS AS A RULE, NOT AS A PLACE TO TYPE. The box grew to fit +// its draft and no further, so the commonest state — a question a sentence +// long — drew a single line between the list above it and the hint below it, +// and a person looking at the screen could not tell the thing they were typing +// into from the two dim rules around it. The complaint was that it is +// invisible, and the cause is that one row of text bounded by two rows of +// chrome has no mass of its own. +// +// SO THE BOX HAS A FLOOR, AND THE FLOOR IS ITS CEILING. Both are three, which +// makes the typing area a block of ONE HEIGHT: it does not grow under the hand +// as a sentence wraps, and the list above it does not step down a row when it +// does. A box that changed height while somebody typed into it was the other +// half of the same complaint, and the two are fixed by one number rather than +// by two that have to be kept apart. +// +// The rows are padded BELOW the draft, never above it, so the first line a +// person types stays on the first row and the caret arithmetic in pages.go is +// untouched — it derives the caret's row by subtracting this block's height +// from the rows placed, and a pad at the bottom moves both by the same amount. +// Past three rows the window still scrolls under the caret ([draftBlock]), so a +// paste cannot push the list off the screen. +// +// AT REST IT IS STILL ONE ROW. An empty box draws the place's own dim sentence +// and nothing else, which is the list-first shape this screen is built on and +// the emptiness law besides: a box nobody is typing in has nothing to show, and +// three blank rows held open for it would be the cockpit this screen +// deliberately is not. +const homeDraftFloor = 3 + // The sentences this surface says. Each is quoted in the manual exactly as it // is spelled here. const ( diff --git a/internal/tui3/pages.go b/internal/tui3/pages.go index da7efe9b05..2e3fb095f2 100644 --- a/internal/tui3/pages.go +++ b/internal/tui3/pages.go @@ -1185,6 +1185,15 @@ func placeFrameWithBar(a *app, width, height int, a.target.pick.hintAt(width-2-ansi.StringWidth(prompt)), "") case box != nil && !box.empty() && !a.placeBoxOnBody(): draftRows, draftCX, draftCY = draftBlock(box, pal, width-2, homeDraftRows, "", "") + // THE BOX HAS A FLOOR ONCE IT HOLDS SOMETHING ([homeDraftFloor]), and the + // rows that make it up are added BELOW what was typed. Padding above would + // move the first line a person typed off the first row, and the caret's own + // row is derived by subtracting this block's height from the rows placed + // below — so a pad at the bottom moves both by the same amount and the + // caret stays on the letter it is on. + for len(draftRows) < homeDraftFloor { + draftRows = append(draftRows, "") + } } draftHeight := len(draftRows) if draftHeight < 1 { From 27dfc4497202aed8e5c2a37c844fff7075ebdcbe Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 12 Sep 2026 10:01:26 -0400 Subject: [PATCH 2/9] home: the box is three rows at rest too, and aforge engine --stop-all The owner's reading: a box that is only three rows once you type in it is still invisible before you type, and somebody who cannot find the box has nothing to type into it. So the resting state holds the same rows open under its dim sentence. The foot no longer moves on the first keystroke either, which was the same complaint from the other side. placeFootRows is derived from homeDraftFloor now rather than spelled, because the composer being ONE HEIGHT at rest and in use is what makes that number a constant at all. And aforge engine --stop-all, because the stale-engine refusal names one workspace and a person who does not know which folder is stuck was left reading a directory of hashes. --- cmd/aforge/engine.go | 83 ++++++++++++++++++- .../unreleased/997-home-input-three-rows.md | 15 +++- internal/enginehost/enginehost.go | 44 ++++++++++ internal/manual/chat/home.md | 8 +- .../manual/chat/staying-on-that-machine.md | 37 ++++++++- internal/tui3/pages.go | 20 ++++- internal/tui3/placelaws_test.go | 24 ++++-- 7 files changed, 212 insertions(+), 19 deletions(-) diff --git a/cmd/aforge/engine.go b/cmd/aforge/engine.go index af8d544945..71a7d9388a 100644 --- a/cmd/aforge/engine.go +++ b/cmd/aforge/engine.go @@ -85,6 +85,13 @@ func runRemoteEngine(args []string) error { // refusal below sends them to it: something older is holding this // workspace and has to be let go of before a current build can hold it. stop := flags.Bool("stop", false, "stop whatever is holding this workspace's conversations on this machine") + // --stop-all IS THE ONE A PERSON REACHES FOR WHEN THEY DO NOT KNOW WHICH + // WORKSPACE IS THE PROBLEM, and that is the ordinary case: the refusal names + // a machine, a person has run aforge in six folders this month, and finding + // the one that will not let go means reading a directory of hashes. It is + // the same stand-down as --stop, asked of every workspace this machine has a + // host directory for, one at a time and named as it goes. + stopAll := flags.Bool("stop-all", false, "stop every engine this machine is holding, in every workspace") if err := parseCommandFlags(flags, args); err != nil { return err } @@ -93,12 +100,18 @@ func runRemoteEngine(args []string) error { // and nothing a person accomplishes by typing it, so the usage line // offers the two flags somebody might mean and stays quiet about the // one they would only ever mean by accident. - return fmt.Errorf("usage: aforge engine [--workspace path] [--session path] [--no-host] [--stop]") + return fmt.Errorf("usage: aforge engine [--workspace path] [--session path] [--no-host] [--stop] [--stop-all]") } if *daemon { return runEngineHost(*workspace, *file) } + if *stopAll { + // THE TWO FLAGS ARE NOT COMBINED, they are ordered: --stop-all is a + // superset of --stop, so a person who typed both meant the larger one + // and being refused for saying it twice would be pedantry. + return runEngineStopAll() + } if *stop { return runEngineStop(*workspace) } @@ -399,6 +412,74 @@ func runEngineStop(workspaceFlag string) error { return nil } +// runEngineStopAll is `aforge engine --stop-all`: every engine this machine is +// holding, in every workspace, let go of. +// +// IT EXISTS BECAUSE THE REMEDY USED TO REQUIRE KNOWING THE ANSWER. A stale host +// announces itself by refusing a launch, and the fix is `--stop --workspace +// ` — but the person reading that has run aforge in six folders and the +// state root names them by hash. On 2026-09-12 a host on an older wire held one +// checkout for twenty-two hours and eight rebuilds, and clearing it took reading +// a directory of hashes to find which one it was. This is that reading, done by +// the program. +// +// EVERY WORKSPACE IS NAMED AS IT GOES, and one that refuses does not stop the +// sweep: the whole point is the workspace you did not know about, so a failure +// on the third of five must not hide the fourth. The refusals are collected and +// reported together at the end, and the exit code says whether any of them +// happened. +// +// A DIRECTORY WHOSE HOST HAS GONE IS NOT A FAILURE. [enginehost.Stop] answers +// false for a socket nobody is listening on, which is the ordinary state of +// every workspace anybody has ever opened and closed, so those are counted and +// summarised rather than printed one by one — a sweep that listed thirteen +// "nothing there" lines would bury the one line that mattered. +func runEngineStopAll() error { + held, err := enginehost.Held() + if err != nil { + return err + } + if len(held) == 0 { + fmt.Println("nothing is holding any workspace here") + return nil + } + var stopped, quiet int + var refused []string + for _, workspace := range held { + went, err := enginehost.Stop(workspace) + switch { + case err != nil: + refused = append(refused, fmt.Sprintf("%s: %v", workspace, err)) + case went: + stopped++ + fmt.Printf("stopped holding %s\n", workspace) + default: + quiet++ + } + } + if stopped == 0 && len(refused) == 0 { + fmt.Printf("nothing is holding any of the %s here\n", placesWord(quiet)) + return nil + } + if quiet > 0 { + fmt.Printf("%s had nothing holding them\n", placesWord(quiet)) + } + if len(refused) > 0 { + return fmt.Errorf("could not stop %s:\n %s", placesWord(len(refused)), strings.Join(refused, "\n ")) + } + fmt.Println("the next connection in any of them starts fresh from this build") + return nil +} + +// placesWord counts workspaces the way a sentence does, because "1 workspaces" +// is the kind of line that makes a person doubt the number beside it. +func placesWord(n int) string { + if n == 1 { + return "1 workspace" + } + return fmt.Sprintf("%d workspaces", n) +} + // runEngineHost is this process being the host: it moves into the workspace // once, the way every engine does, and then holds that workspace's // conversations until the idle policy retires it. diff --git a/docs/changes/unreleased/997-home-input-three-rows.md b/docs/changes/unreleased/997-home-input-three-rows.md index 75c5cb1b5a..dd78972947 100644 --- a/docs/changes/unreleased/997-home-input-three-rows.md +++ b/docs/changes/unreleased/997-home-input-three-rows.md @@ -1,14 +1,23 @@ --- kind: changed -title: Home's box is three rows tall the moment you type in it +title: Home's box is three rows tall, typed in or not, and one command clears every engine pr: 997 -surface: [chat] +surface: [chat, engine, docs] invalidates: - "Home's foot box grew to fit its draft and no further, so the commonest state — a question one sentence long — drew a SINGLE row between the rule above it and the hint below it. The owner's reading was that it is too thin to notice and does not look like somewhere to type, and the cause is that one row of text bounded by two rows of chrome has no mass of its own. It now stands three rows high from the first character (`homeDraftFloor`), padded below the draft so the first line typed stays on the first row." - "The box no longer changes height while somebody types. The floor and the ceiling are both three, so a sentence that wraps to a second row does not grow the box and does not step the list above it down a row. Anything that remembers home's box as growing with its content is wrong; past three rows the window scrolls under the caret exactly as it did." - - "An empty box is still ONE row, drawing the place's own dim sentence. That is unchanged and deliberate: this screen is a list first, and three blank rows held open for a box nobody is typing in would be the cockpit it is not." + - "An empty box is three rows as well, not one. The place's dim sentence is on the first row and the other two are held open under it, so the one thing on the screen a person types into is a block they can see BEFORE they have typed anything — somebody who cannot find the box has nothing to type into it. It also means the foot does not move on the first keystroke, where a box that jumped from one row to three would shift the list up under the hand reaching for it. The resting rows are the box's silhouette and not its surface: the press span stays empty, so a click on them falls through to the place underneath exactly as a click on the resting row always has." --- +`aforge engine --stop-all` is here too, because it is the other half of the same +afternoon: the stale-engine refusal names one workspace, and a person who does +not know WHICH of their folders is the problem was left reading a directory of +hashes. `enginehost.Held` reads the plain-text workspace each host writes beside +its socket, and the sweep stands every one of them down, naming each as it goes. +A directory whose host has already gone is counted rather than printed, so a +sweep does not bury the one line that matters under thirteen saying nothing was +there. + The caret arithmetic in `pages.go` is untouched. It derives the caret's row by subtracting the block's height from the rows placed, so padding at the bottom moves both by the same amount — verified by driving the real binary against the diff --git a/internal/enginehost/enginehost.go b/internal/enginehost/enginehost.go index 5f9c38a1b8..6bf32b9531 100644 --- a/internal/enginehost/enginehost.go +++ b/internal/enginehost/enginehost.go @@ -43,6 +43,7 @@ import ( "os" "os/exec" "path/filepath" + "sort" "strings" "time" @@ -266,6 +267,49 @@ func Ask(workspace string, ask remote.WhoIs) (remote.HostSelf, error) { // asks for it regardless, which is one person's own `aforge engine --stop` and // nothing else. Either way the ending is the host's own shutdown — every // conversation closed, every journal flushed — and never a signal from outside. +// Held is every workspace this machine has a host directory for, in the plain +// text each host wrote there ([placeName]). +// +// IT READS THE DIRECTORIES AND NOT THE PROCESS TABLE. A host is known by the +// state it left under ~/.aforge/v3/hosts, so this answers for hosts started by +// any build and by any terminal, including one whose process has gone and left +// its socket behind. Whether anybody is actually listening is the caller's next +// question, asked through [Dial] or [Stop] — and a directory whose host is gone +// answers "nothing was holding it", which is the truth a sweep wants. +// +// A directory with no workspace file is SKIPPED rather than guessed at: the +// name is a hash and there is no way back from it to a path, so the honest +// answer for one is nothing at all. Nothing here is an error a caller should +// stop for — a state root that cannot be read is a machine with no hosts. +func Held() ([]string, error) { + root, err := home.Join("v3", "hosts"), error(nil) + if err != nil { + return nil, err + } + entries, err := os.ReadDir(root) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("engine host: %w", err) + } + var held []string + for _, entry := range entries { + if !entry.IsDir() { + continue + } + name, err := os.ReadFile(filepath.Join(root, entry.Name(), placeName)) + if err != nil { + continue + } + if workspace := strings.TrimSpace(string(name)); workspace != "" { + held = append(held, workspace) + } + } + sort.Strings(held) + return held, nil +} + func Retire(workspace string, anyway bool) error { self, err := Ask(workspace, remote.WhoIs{StandDown: true, Anyway: anyway}) if err != nil { diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index 1ec4813720..7c8ac36b29 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -714,9 +714,11 @@ does not step down a row when it does. Past three rows the window scrolls with t marked with `…` where the `›` was. Nothing you type is ever truncated out of view. There is no key to open a new line here (that is the chat box's `ctrl+j`). -**An empty box is one row.** With nothing typed it draws the dim sentence about what this -screen does and nothing else, because a box nobody is typing in has nothing to show. The -three rows arrive with the first character. +**An empty box is three rows too.** With nothing typed it draws the dim sentence about +what this screen does on its first row and holds the other two open under it, so the one +thing on this screen you type into is a block you can see *before* you have typed anything. +It also means the foot does not move on the first keystroke: a box that jumped from one row +to three the moment a letter landed would shift the list up under the hand reaching for it. **`alt+enter` is a task and not `ask here`.** On home and on every other place the chord opens the **composer layer**, where the three facts a task needs are settled — where, on diff --git a/internal/manual/chat/staying-on-that-machine.md b/internal/manual/chat/staying-on-that-machine.md index 532c52904f..c933bbbd0b 100644 --- a/internal/manual/chat/staying-on-that-machine.md +++ b/internal/manual/chat/staying-on-that-machine.md @@ -389,9 +389,40 @@ nothing is holding /home/you/api here ``` `--workspace` picks which one; with no flag it means your home directory, exactly as it does -for `aforge engine` itself. The reason to type it is that a connection told you to: both -refusals about an older aforge still holding a machine name this command (*Running on -another machine*). +for `aforge engine` itself. **Type the flag.** Without it you stop whatever is holding your +home directory, which is usually not the folder that refused you — and the refusal comes +back on the next launch because the engine it was about is still running. Both refusals +about an older aforge spell the workspace out in the command they give you (*Running on +another machine*); copy the line as written. + +## Stop every engine on this machine — aforge engine --stop-all, I don't know which folder is stuck + +When you do not know which workspace is the problem, do not go looking for it: + +``` +aforge engine --stop-all +``` + +It stands down every engine this machine is holding, in every workspace, one at a time, and +names each one as it goes: + +``` +stopped holding /home/you/api +stopped holding /home/you/site +11 workspaces had nothing holding them +the next connection in any of them starts fresh from this build +``` + +Folders you once opened and have long since closed are counted on that one line rather than +listed, so the workspaces that actually let go are the ones you read. A workspace that +refuses does not stop the sweep — the whole point is the folder you did not know about, so a +failure on the third of five must not hide the fourth — and every refusal is reported +together at the end. + +This is the blunt instrument and it is safe to reach for: an engine that is stopped closes +its conversations and flushes their transcripts first, and a turn it catches keeps its +partial reply. What it costs you is that every conversation on this machine comes back on +the next connection rather than staying warm. ## Rebuilt aforge but your conversation was still on the old engine — how aforge tells you diff --git a/internal/tui3/pages.go b/internal/tui3/pages.go index 2e3fb095f2..898867cfcb 100644 --- a/internal/tui3/pages.go +++ b/internal/tui3/pages.go @@ -1195,9 +1195,17 @@ func placeFrameWithBar(a *app, width, height int, draftRows = append(draftRows, "") } } + // THE BOX IS THE SAME HEIGHT TYPED IN OR NOT ([homeDraftFloor]). At rest it + // is the place's dim sentence with the same rows under it, so the one thing + // on this screen a person types into is a block they can see before they + // have typed anything — which is the whole point, since somebody who cannot + // find the box has nothing to type into it. It also means the foot does not + // move on the first keystroke: a box that jumped from one row to three the + // moment a letter landed would shift the list up under the hand that was + // reaching for it. draftHeight := len(draftRows) - if draftHeight < 1 { - draftHeight = 1 + if draftHeight < homeDraftFloor { + draftHeight = homeDraftFloor } // THE VERB STRIP IS A ROW OF THE BODY AND THE ANSWER STRIP IS A ROW OF THE // FOOT, and they are asked for separately because they are two different @@ -1366,6 +1374,14 @@ func placeFrameWithBar(a *app, width, height int, boxTop, boxHeight := len(lines), len(draftRows) if len(draftRows) == 0 { add(a.placeChipped(" "+pal.dim(fit(a.placeRestWord(), width-2)), chip, width, pal), nil) + // AND THE REST OF THE BLOCK IS HELD OPEN UNDER IT, so the box is the same + // shape before the first keystroke as after it. The span stays EMPTY + // (boxHeight is still zero above): these rows are the box's silhouette + // and not its surface, so a press on them falls through to the place + // underneath exactly as a press on the resting row always has. + for row := 1; row < homeDraftFloor; row++ { + add("", nil) + } // AT REST THERE IS NOTHING TO TYPE INTO, so the caret is hidden rather // than left blinking at the frame's origin. The moment a character lands // the box stops being empty and the caret comes back, in the box. diff --git a/internal/tui3/placelaws_test.go b/internal/tui3/placelaws_test.go index 2ca848cbea..50525326a5 100644 --- a/internal/tui3/placelaws_test.go +++ b/internal/tui3/placelaws_test.go @@ -298,8 +298,13 @@ func TestEveryPlaceTakesExactlyTheWholeFrameAtEveryWidth(t *testing.T) { // that can be empty, at three sizes, and every edge has to be on the same row. // // placeFootRows is that foot at rest, counted: the blank over the rule, the rule -// with the note on it, the composer and the hint. -const placeFootRows = 4 +// with the note on it, the composer's [homeDraftFloor] rows and the hint. +// +// THE COMPOSER IS THE SAME HEIGHT AT REST AS IN USE, which is what makes this +// number a constant at all. It was three when the box was one row and grew with +// what was typed into it; a box that changed height moved this whole foot under +// the hand, and the law below could only be stated about a resting screen. +const placeFootRows = 3 + homeDraftFloor func TestEveryPlaceSpendsTheSameHeadAndFoot(t *testing.T) { type edges struct{ bar, headRule, blank, footRule, box, hint int } @@ -309,7 +314,7 @@ func TestEveryPlaceSpendsTheSameHeadAndFoot(t *testing.T) { for _, size := range sizes { height := size[1] want[size] = edges{bar: placeTabRow, headRule: 2, blank: placeHeadRows - 1, - footRule: height - placeFootRows + 1, box: height - 2, hint: height - 1} + footRule: height - placeFootRows + 1, box: height - homeDraftFloor, hint: height - 1} } for _, lab := range labs { for _, size := range sizes { @@ -327,11 +332,16 @@ func TestEveryPlaceSpendsTheSameHeadAndFoot(t *testing.T) { if strings.TrimSpace(rows[placeHeadRows-1]) == "" { got.blank = placeHeadRows - 1 } - if strings.HasPrefix(rows[len(rows)-3], "─") { - got.footRule = len(rows) - 3 + // THE RULE AND THE BOX ARE FOUND BY COUNTING BACK THROUGH THE + // COMPOSER'S OWN HEIGHT, not by two fixed offsets. The box is + // [homeDraftFloor] rows and its FIRST row is the one carrying the + // prompt, so the rule sits one above that and the rows between the + // prompt and the hint are the composer's own. + if at := len(rows) - 1 - homeDraftFloor; at >= 0 && strings.HasPrefix(rows[at], "─") { + got.footRule = at } - if strings.HasPrefix(rows[len(rows)-2], " "+prompt) { - got.box = len(rows) - 2 + if at := len(rows) - homeDraftFloor; at >= 0 && strings.HasPrefix(rows[at], " "+prompt) { + got.box = at } if got != want[size] { t.Errorf("the %s place at %dx%d puts its edges at %+v, and every place puts them at %+v\n%s", From 1902b67a6201f792bda2270791aedb70f14393ab Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 12 Sep 2026 10:18:17 -0400 Subject: [PATCH 3/9] home: the resting box gives its rows back on a frame too short to spare them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three-row floor cost every place two body rows at every height, and on an eight-row terminal that left one row of list under a four-row head — the content somebody came to read giving way to the emptiness around the box. placeBoxFloor is the one answer, handed the frame's own height, feeding both the rows the foot reserves and the rows it then draws. placeFootRows moves out of the law test and into pages.go beside it, so the law reads the number back off the frame rather than keeping a second copy that would go on passing after the foot moved. Co-Authored-By: Claude Opus 5 --- .../unreleased/997-home-input-three-rows.md | 1 + internal/manual/chat/home.md | 5 ++ internal/tui3/pages.go | 51 +++++++++++++++++-- internal/tui3/placelaws_test.go | 22 ++++---- 4 files changed, 65 insertions(+), 14 deletions(-) diff --git a/docs/changes/unreleased/997-home-input-three-rows.md b/docs/changes/unreleased/997-home-input-three-rows.md index dd78972947..d8822eb470 100644 --- a/docs/changes/unreleased/997-home-input-three-rows.md +++ b/docs/changes/unreleased/997-home-input-three-rows.md @@ -7,6 +7,7 @@ invalidates: - "Home's foot box grew to fit its draft and no further, so the commonest state — a question one sentence long — drew a SINGLE row between the rule above it and the hint below it. The owner's reading was that it is too thin to notice and does not look like somewhere to type, and the cause is that one row of text bounded by two rows of chrome has no mass of its own. It now stands three rows high from the first character (`homeDraftFloor`), padded below the draft so the first line typed stays on the first row." - "The box no longer changes height while somebody types. The floor and the ceiling are both three, so a sentence that wraps to a second row does not grow the box and does not step the list above it down a row. Anything that remembers home's box as growing with its content is wrong; past three rows the window scrolls under the caret exactly as it did." - "An empty box is three rows as well, not one. The place's dim sentence is on the first row and the other two are held open under it, so the one thing on the screen a person types into is a block they can see BEFORE they have typed anything — somebody who cannot find the box has nothing to type into it. It also means the foot does not move on the first keystroke, where a box that jumped from one row to three would shift the list up under the hand reaching for it. The resting rows are the box's silhouette and not its surface: the press span stays empty, so a click on them falls through to the place underneath exactly as a click on the resting row always has." + - "On a frame under about thirteen rows the box is one row again. Three held-open rows over a four-row head and a three-row foot left the list one row, which is the content the person came for giving way to the emptiness around the box. `placeBoxFloor` takes the floor only while the body keeps `placeBodyFloor` rows, and the same answer feeds the height the foot reserves and the rows it draws, so the two can never disagree." --- `aforge engine --stop-all` is here too, because it is the other half of the same diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index 7c8ac36b29..0bc25229f4 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -720,6 +720,11 @@ thing on this screen you type into is a block you can see *before* you have type It also means the foot does not move on the first keystroke: a box that jumped from one row to three the moment a letter landed would shift the list up under the hand reaching for it. +**On a very short terminal the box is one row again.** Under about thirteen rows there is +no room to hold three open and still leave a list worth reading above them, so the box +falls back to the single row it has always drawn and the list keeps its rows. The box is +just as usable — what gives way is the space around it, not what you came to read. + **`alt+enter` is a task and not `ask here`.** On home and on every other place the chord opens the **composer layer**, where the three facts a task needs are settled — where, on what, how much (the places page, *the composer layer*) — and a second `alt+enter` sends it diff --git a/internal/tui3/pages.go b/internal/tui3/pages.go index 898867cfcb..ab1da661e3 100644 --- a/internal/tui3/pages.go +++ b/internal/tui3/pages.go @@ -1185,13 +1185,13 @@ func placeFrameWithBar(a *app, width, height int, a.target.pick.hintAt(width-2-ansi.StringWidth(prompt)), "") case box != nil && !box.empty() && !a.placeBoxOnBody(): draftRows, draftCX, draftCY = draftBlock(box, pal, width-2, homeDraftRows, "", "") - // THE BOX HAS A FLOOR ONCE IT HOLDS SOMETHING ([homeDraftFloor]), and the + // THE BOX HAS A FLOOR ([placeBoxFloor]), and the // rows that make it up are added BELOW what was typed. Padding above would // move the first line a person typed off the first row, and the caret's own // row is derived by subtracting this block's height from the rows placed // below — so a pad at the bottom moves both by the same amount and the // caret stays on the letter it is on. - for len(draftRows) < homeDraftFloor { + for floor := placeBoxFloor(height); len(draftRows) < floor; { draftRows = append(draftRows, "") } } @@ -1204,8 +1204,8 @@ func placeFrameWithBar(a *app, width, height int, // moment a letter landed would shift the list up under the hand that was // reaching for it. draftHeight := len(draftRows) - if draftHeight < homeDraftFloor { - draftHeight = homeDraftFloor + if floor := placeBoxFloor(height); draftHeight < floor { + draftHeight = floor } // THE VERB STRIP IS A ROW OF THE BODY AND THE ANSWER STRIP IS A ROW OF THE // FOOT, and they are asked for separately because they are two different @@ -1379,7 +1379,7 @@ func placeFrameWithBar(a *app, width, height int, // (boxHeight is still zero above): these rows are the box's silhouette // and not its surface, so a press on them falls through to the place // underneath exactly as a press on the resting row always has. - for row := 1; row < homeDraftFloor; row++ { + for row := 1; row < placeBoxFloor(height); row++ { add("", nil) } // AT REST THERE IS NOTHING TO TYPE INTO, so the caret is hidden rather @@ -1626,6 +1626,47 @@ const ( mapCloseWords = "esc close" ) +// placeFootRows is how many rows every place spends under its body when it has +// nothing extra to say: the blank ([spacingRuleClearance]), the rule, the box +// at its floor, and the hint. A note, a tray, a verb strip or the composer +// layer each add their own rows on top of these, which is why the frame counts +// them separately — this is the floor of the foot, not its whole height, and it +// is the number every place is measured against ([placeFrameWithBar] builds it +// row by row and TestEveryPlaceSpendsTheSameHeadAndFoot reads it back). +const placeFootRows = 3 + homeDraftFloor + +// placeBodyFloor is the fewest rows of list a place is drawn with before the +// composer gives its own height back. Three is a row to stand on, a row above +// it and a row below it, which is the least that reads as a list at all. +const placeBodyFloor = 3 + +// placeBoxFloor is how many rows the composer occupies, and it is the same +// number typed in or not ([homeDraftFloor]) — except on a frame with no room +// for it. +// +// A THREE-ROW BOX ON AN EIGHT-ROW TERMINAL IS THE LIST GONE. The foot already +// spends a blank, a rule and a hint; holding three more open on top of a +// four-row head leaves one body row, and the list a person came to read touches +// the foot. So the floor is taken only while the body keeps [placeBodyFloor] +// rows under it, and a frame too short falls back to the single row the box has +// always drawn — the box stays usable, and what gives way is the emptiness +// around it rather than the content above it. +// +// THE SAME ANSWER FEEDS THE HEIGHT AND THE DRAWING, so the rows the foot +// reserves and the rows it then adds can never disagree. That is not tidiness: +// they are read a hundred lines apart and a frame that reserved three and drew +// one would lose a row off the top of the window, which is where the head is. +// It is handed the frame's own height rather than asking [app.size] for one, +// because a frame is drawn at the height it was given — the rigs draw many +// sizes through one app, and a floor decided from the window would be the wrong +// floor for every frame but the last. +func placeBoxFloor(height int) int { + if height-(placeHeadRows+placeFootRows) < placeBodyFloor { + return 1 + } + return homeDraftFloor +} + // placeRestWord is what this place's box row says with nothing typed in it. // // IT IS THE SAME SENTENCE ON EVERY PLACE THAT SENDS ONE — home included (SCREEN diff --git a/internal/tui3/placelaws_test.go b/internal/tui3/placelaws_test.go index 50525326a5..42c0139928 100644 --- a/internal/tui3/placelaws_test.go +++ b/internal/tui3/placelaws_test.go @@ -297,14 +297,18 @@ func TestEveryPlaceTakesExactlyTheWholeFrameAtEveryWidth(t *testing.T) { // (PLACES-AUDIT.md finding 1). One loop over the registry and over every place // that can be empty, at three sizes, and every edge has to be on the same row. // -// placeFootRows is that foot at rest, counted: the blank over the rule, the rule -// with the note on it, the composer's [homeDraftFloor] rows and the hint. +// [placeFootRows] is that foot at rest, counted where it is drawn: the blank +// over the rule, the rule with the note on it, the composer's [homeDraftFloor] +// rows and the hint. This law reads the number back off the frame rather than +// keeping a second copy of it, because a law that owned its own arithmetic +// would go on passing after the foot moved. // // THE COMPOSER IS THE SAME HEIGHT AT REST AS IN USE, which is what makes this -// number a constant at all. It was three when the box was one row and grew with -// what was typed into it; a box that changed height moved this whole foot under -// the hand, and the law below could only be stated about a resting screen. -const placeFootRows = 3 + homeDraftFloor +// number a constant at all. It was one row at rest and grew to three with what +// was typed into it; a box that changed height moved this whole foot under the +// hand, and the law below could only be stated about a resting screen. The +// sizes here are all tall enough to hold that floor ([placeBoxFloor] gives it +// back on a frame that is not). func TestEveryPlaceSpendsTheSameHeadAndFoot(t *testing.T) { type edges struct{ bar, headRule, blank, footRule, box, hint int } @@ -314,7 +318,7 @@ func TestEveryPlaceSpendsTheSameHeadAndFoot(t *testing.T) { for _, size := range sizes { height := size[1] want[size] = edges{bar: placeTabRow, headRule: 2, blank: placeHeadRows - 1, - footRule: height - placeFootRows + 1, box: height - homeDraftFloor, hint: height - 1} + footRule: height - placeFootRows + 1, box: height - 1 - homeDraftFloor, hint: height - 1} } for _, lab := range labs { for _, size := range sizes { @@ -337,10 +341,10 @@ func TestEveryPlaceSpendsTheSameHeadAndFoot(t *testing.T) { // [homeDraftFloor] rows and its FIRST row is the one carrying the // prompt, so the rule sits one above that and the rows between the // prompt and the hint are the composer's own. - if at := len(rows) - 1 - homeDraftFloor; at >= 0 && strings.HasPrefix(rows[at], "─") { + if at := len(rows) - 2 - homeDraftFloor; at >= 0 && strings.HasPrefix(rows[at], "─") { got.footRule = at } - if at := len(rows) - homeDraftFloor; at >= 0 && strings.HasPrefix(rows[at], " "+prompt) { + if at := len(rows) - 1 - homeDraftFloor; at >= 0 && strings.HasPrefix(rows[at], " "+prompt) { got.box = at } if got != want[size] { From 04e31f29508a346cfdeeca75fca46931057e95b8 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 12 Sep 2026 10:19:07 -0400 Subject: [PATCH 4/9] docs: the change entry carries this pull request's number 997 is somebody else's pull request. The entry is renamed and its pr: field corrected so the ledger points at the change it describes. Co-Authored-By: Claude Opus 5 --- ...7-home-input-three-rows.md => 1000-home-input-three-rows.md} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename docs/changes/unreleased/{997-home-input-three-rows.md => 1000-home-input-three-rows.md} (99%) diff --git a/docs/changes/unreleased/997-home-input-three-rows.md b/docs/changes/unreleased/1000-home-input-three-rows.md similarity index 99% rename from docs/changes/unreleased/997-home-input-three-rows.md rename to docs/changes/unreleased/1000-home-input-three-rows.md index d8822eb470..3b2660d026 100644 --- a/docs/changes/unreleased/997-home-input-three-rows.md +++ b/docs/changes/unreleased/1000-home-input-three-rows.md @@ -1,7 +1,7 @@ --- kind: changed title: Home's box is three rows tall, typed in or not, and one command clears every engine -pr: 997 +pr: 1000 surface: [chat, engine, docs] invalidates: - "Home's foot box grew to fit its draft and no further, so the commonest state — a question one sentence long — drew a SINGLE row between the rule above it and the hint below it. The owner's reading was that it is too thin to notice and does not look like somewhere to type, and the cause is that one row of text bounded by two rows of chrome has no mass of its own. It now stands three rows high from the first character (`homeDraftFloor`), padded below the draft so the first line typed stays on the first row." From 40dc35ed09ae66da526f9b1ec5986311dc47f6a3 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 12 Sep 2026 10:27:52 -0400 Subject: [PATCH 5/9] the composer's floor is the chat's floor too, through one door on each surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A three-row box on home and a one-row box in the conversation moves the rule, the box and the last row every time `esc` crosses between them — which is the defect PLACES-AUDIT lane K found and fixed, put straight back by a floor that held on one surface. TestWalkingBetweenAChatAndThePlacesMovesNothingAtTheFoot is the law, and it names the shape rather than the number. So boxFloor is asked by the chat's inputBlock and by every place's frame. In the chat it is applied inside inputBlock, which inputHeight and the drawing both go through, so the rows reserved and the rows drawn cannot disagree. On a place the padding moves out of the typed branch and onto every branch, which also closes a hole the first cut left: the target picker's filter returned one row while the foot had already reserved three, and the frame lost two rows off its top. placeBoxFloor is renamed boxFloor, since it now governs both surfaces. Co-Authored-By: Claude Opus 5 --- internal/tui3/head_test.go | 6 +++- internal/tui3/home.go | 39 +++++++++++++++++--------- internal/tui3/input.go | 28 ++++++++++++++++++- internal/tui3/pages.go | 57 +++++++++++++++++++++----------------- 4 files changed, 90 insertions(+), 40 deletions(-) diff --git a/internal/tui3/head_test.go b/internal/tui3/head_test.go index 74d7def0f9..c9248b626e 100644 --- a/internal/tui3/head_test.go +++ b/internal/tui3/head_test.go @@ -170,7 +170,11 @@ func TestWalkingBetweenAChatAndThePlacesMovesNothingAtTheFoot(t *testing.T) { a := headLab(t) for _, size := range headFrameSizes { a.width, a.height = size.w, size.h - want := footEdges{rule: size.h - placeFootRows + 1, box: size.h - 2, status: size.h - 1} + // The box row is the row the PROMPT is on, which is the block's first of + // [homeDraftFloor] ([footOf] reads it back that way), so it stands as far + // above the last row as the floor is deep. + want := footEdges{rule: size.h - placeFootRows + 1, + box: size.h - 1 - homeDraftFloor, status: size.h - 1} for _, to := range []page{pageNone, pageHome, pageTasks, pageNone} { if to == pageNone { a.showPage(pageNone) diff --git a/internal/tui3/home.go b/internal/tui3/home.go index 8909045751..c557fb2aa6 100644 --- a/internal/tui3/home.go +++ b/internal/tui3/home.go @@ -254,8 +254,8 @@ const ( // the caret exactly as the chat box's does (input.go's draftBlock). const homeDraftRows = 3 -// homeDraftFloor is how many rows home's foot box occupies ONCE SOMETHING IS -// TYPED IN IT, however little that is. +// homeDraftFloor is how many rows the composer occupies WHATEVER IS IN IT, +// including nothing. // // A ONE-ROW BOX READS AS A RULE, NOT AS A PLACE TO TYPE. The box grew to fit // its draft and no further, so the commonest state — a question a sentence @@ -273,17 +273,30 @@ const homeDraftRows = 3 // by two that have to be kept apart. // // The rows are padded BELOW the draft, never above it, so the first line a -// person types stays on the first row and the caret arithmetic in pages.go is -// untouched — it derives the caret's row by subtracting this block's height -// from the rows placed, and a pad at the bottom moves both by the same amount. -// Past three rows the window still scrolls under the caret ([draftBlock]), so a -// paste cannot push the list off the screen. -// -// AT REST IT IS STILL ONE ROW. An empty box draws the place's own dim sentence -// and nothing else, which is the list-first shape this screen is built on and -// the emptiness law besides: a box nobody is typing in has nothing to show, and -// three blank rows held open for it would be the cockpit this screen -// deliberately is not. +// person types stays on the first row and the caret arithmetic is untouched — +// it derives the caret's row by subtracting this block's height from the rows +// placed, and a pad at the bottom moves both by the same amount. Past three +// rows the window still scrolls under the caret ([draftBlock]), so a paste +// cannot push the list off the screen. +// +// AT REST IT IS THREE ROWS TOO, and that is the point rather than a side +// effect: the one thing on this screen a person types into should be a block +// they can see BEFORE they have typed anything, and a box that jumped from one +// row to three on the first keystroke shifted the list up under the hand +// reaching for it. The resting rows are the box's silhouette and not its +// surface — the press span stays empty, so a click on them falls through to the +// place underneath exactly as a click on the resting row always has, and the +// emptiness law is untouched because the row still says nothing it does not +// know. +// +// AND IT IS THE CHAT'S FLOOR AS WELL AS EVERY PLACE'S. The conversation's foot +// and a place's foot are the same rows — a blank, the rule, the box, the last +// line — and `esc` between them may not move any of them +// (TestWalkingBetweenAChatAndThePlacesMovesNothingAtTheFoot, which is a defect +// that was found and fixed once already). A floor that held on home alone would +// put it straight back, so [boxFloor] is asked by the chat's [app.inputBlock] +// and by every place's frame, and the number they are both held to is this one. +// It lives here because home is where the reading that set it came from. const homeDraftFloor = 3 // The sentences this surface says. Each is quoted in the manual exactly as it diff --git a/internal/tui3/input.go b/internal/tui3/input.go index 88fb25e3bb..a04271bc5b 100644 --- a/internal/tui3/input.go +++ b/internal/tui3/input.go @@ -1467,8 +1467,34 @@ func (a *app) completePath() tea.Cmd { } // inputBlock renders the draft — or the picker's filter box in its place — and -// says where the caret sits inside it. +// says where the caret sits inside it, held open to the composer's floor. +// +// THE FLOOR IS APPLIED HERE AND NOWHERE ELSE, because this is the one function +// every geometric question below the conversation goes through +// ([app.inputHeight] asks it for the rows it will subtract, and view.go asks it +// for the rows it will draw). A floor applied at the drawing would be a floor +// the height did not know about, and rows the geometry did not subtract are +// rows [app.frameOut] loses off the TOP of the window, which is where the +// room's header is. +// +// AND IT IS APPLIED TO EVERY BOX THAT STANDS IN THE DRAFT'S POSITION, not only +// to the draft: a picker's filter, the rewind bar, the watch line and the +// secret row each take this row while something else has the keyboard, and a +// one-row stand-in under a three-row draft would move the whole foot the moment +// the overlay opened — which is the defect below, one size smaller. func (a *app) inputBlock(width int) ([]string, int, int) { + rows, caretX, caretRow := a.inputBlockUnfloored(width) + _, height := a.size() + // The rows are added BELOW what is there, for [placeFrameWithBar]'s reason: + // the caret's row is counted from the head of this block, so a pad at the + // bottom leaves every row above it — and the caret on it — where it was. + for floor := boxFloor(height); len(rows) > 0 && len(rows) < floor; { + rows = append(rows, "") + } + return rows, caretX, caretRow +} + +func (a *app) inputBlockUnfloored(width int) ([]string, int, int) { // The box may not take the frame. Two rows are spoken for whatever happens // — the status line and the blank under it — and what is left over, up to // the ceiling, is the box's: a six-line paste into a four-line window shows diff --git a/internal/tui3/pages.go b/internal/tui3/pages.go index ab1da661e3..eb0dea18fd 100644 --- a/internal/tui3/pages.go +++ b/internal/tui3/pages.go @@ -1185,15 +1185,19 @@ func placeFrameWithBar(a *app, width, height int, a.target.pick.hintAt(width-2-ansi.StringWidth(prompt)), "") case box != nil && !box.empty() && !a.placeBoxOnBody(): draftRows, draftCX, draftCY = draftBlock(box, pal, width-2, homeDraftRows, "", "") - // THE BOX HAS A FLOOR ([placeBoxFloor]), and the - // rows that make it up are added BELOW what was typed. Padding above would - // move the first line a person typed off the first row, and the caret's own - // row is derived by subtracting this block's height from the rows placed - // below — so a pad at the bottom moves both by the same amount and the - // caret stays on the letter it is on. - for floor := placeBoxFloor(height); len(draftRows) < floor; { - draftRows = append(draftRows, "") - } + } + // THE BOX HAS A FLOOR ([boxFloor]) AND EVERY BRANCH ABOVE IS HELD TO IT, the + // target's filter included. The rows that make it up are added BELOW what was + // typed: padding above would move the first line a person typed off the first + // row, and the caret's own row is derived by subtracting this block's height + // from the rows placed below — so a pad at the bottom moves both by the same + // amount and the caret stays on the letter it is on. + // + // An empty block means the box is at REST and the rows are drawn as its + // silhouette further down, so it is left empty here rather than padded into + // a surface a press could land in. + for floor := boxFloor(height); len(draftRows) > 0 && len(draftRows) < floor; { + draftRows = append(draftRows, "") } // THE BOX IS THE SAME HEIGHT TYPED IN OR NOT ([homeDraftFloor]). At rest it // is the place's dim sentence with the same rows under it, so the one thing @@ -1204,7 +1208,7 @@ func placeFrameWithBar(a *app, width, height int, // moment a letter landed would shift the list up under the hand that was // reaching for it. draftHeight := len(draftRows) - if floor := placeBoxFloor(height); draftHeight < floor { + if floor := boxFloor(height); draftHeight < floor { draftHeight = floor } // THE VERB STRIP IS A ROW OF THE BODY AND THE ANSWER STRIP IS A ROW OF THE @@ -1379,7 +1383,7 @@ func placeFrameWithBar(a *app, width, height int, // (boxHeight is still zero above): these rows are the box's silhouette // and not its surface, so a press on them falls through to the place // underneath exactly as a press on the resting row always has. - for row := 1; row < placeBoxFloor(height); row++ { + for row := 1; row < boxFloor(height); row++ { add("", nil) } // AT REST THERE IS NOTHING TO TYPE INTO, so the caret is hidden rather @@ -1640,27 +1644,30 @@ const placeFootRows = 3 + homeDraftFloor // it and a row below it, which is the least that reads as a list at all. const placeBodyFloor = 3 -// placeBoxFloor is how many rows the composer occupies, and it is the same -// number typed in or not ([homeDraftFloor]) — except on a frame with no room -// for it. +// boxFloor is how many rows the composer occupies, and it is the same number +// typed in or not ([homeDraftFloor]) — in the conversation and on every place +// — except on a frame with no room for it. // // A THREE-ROW BOX ON AN EIGHT-ROW TERMINAL IS THE LIST GONE. The foot already // spends a blank, a rule and a hint; holding three more open on top of a -// four-row head leaves one body row, and the list a person came to read touches -// the foot. So the floor is taken only while the body keeps [placeBodyFloor] -// rows under it, and a frame too short falls back to the single row the box has -// always drawn — the box stays usable, and what gives way is the emptiness -// around it rather than the content above it. -// -// THE SAME ANSWER FEEDS THE HEIGHT AND THE DRAWING, so the rows the foot -// reserves and the rows it then adds can never disagree. That is not tidiness: -// they are read a hundred lines apart and a frame that reserved three and drew -// one would lose a row off the top of the window, which is where the head is. +// four-row head leaves one body row, and the list a person came to read gives +// way to the emptiness around the box. So the floor is taken only while the +// body keeps [placeBodyFloor] rows under it, and a frame too short falls back to +// the single row the box has always drawn — the box stays usable, and what gives +// way is the space around it rather than the content above it. +// +// THE SAME ANSWER FEEDS THE HEIGHT AND THE DRAWING, so the rows a foot reserves +// and the rows it then adds can never disagree. That is not tidiness: they are +// read far apart on both surfaces, and a frame that reserved three and drew one +// would lose a row off the top of the window, which is where the head is. It is +// why the chat applies it inside [app.inputBlock], which its height and its +// drawing both go through. +// // It is handed the frame's own height rather than asking [app.size] for one, // because a frame is drawn at the height it was given — the rigs draw many // sizes through one app, and a floor decided from the window would be the wrong // floor for every frame but the last. -func placeBoxFloor(height int) int { +func boxFloor(height int) int { if height-(placeHeadRows+placeFootRows) < placeBodyFloor { return 1 } From dcbd2b6f6c82eb9e32c78b82af26262ba35d09d8 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 12 Sep 2026 10:36:14 -0400 Subject: [PATCH 6/9] the floor is spent out of rows the frame has to spare, not out of the body Held open unconditionally the three rows were not free at eighty by twenty-four: home dropped a whole panel off the bottom of its column, an empty place drew its rule where its whisper had been, and the rail's standing section was squeezed out by a long roster. Three laws in this package exist to catch exactly that, and they caught it. So boxFloor compares the body the floor would leave against the body the smallest frame has under a one-row box, which works out at twenty-six rows and taller. It is written as that comparison rather than as the number, because the number is a consequence of the head and the foot and would be wrong the next time either of them moves. Co-Authored-By: Claude Opus 5 --- .../unreleased/1000-home-input-three-rows.md | 3 +- internal/tui3/pages.go | 44 ++++++++++++------- 2 files changed, 31 insertions(+), 16 deletions(-) diff --git a/docs/changes/unreleased/1000-home-input-three-rows.md b/docs/changes/unreleased/1000-home-input-three-rows.md index 3b2660d026..9bbabef06e 100644 --- a/docs/changes/unreleased/1000-home-input-three-rows.md +++ b/docs/changes/unreleased/1000-home-input-three-rows.md @@ -7,7 +7,8 @@ invalidates: - "Home's foot box grew to fit its draft and no further, so the commonest state — a question one sentence long — drew a SINGLE row between the rule above it and the hint below it. The owner's reading was that it is too thin to notice and does not look like somewhere to type, and the cause is that one row of text bounded by two rows of chrome has no mass of its own. It now stands three rows high from the first character (`homeDraftFloor`), padded below the draft so the first line typed stays on the first row." - "The box no longer changes height while somebody types. The floor and the ceiling are both three, so a sentence that wraps to a second row does not grow the box and does not step the list above it down a row. Anything that remembers home's box as growing with its content is wrong; past three rows the window scrolls under the caret exactly as it did." - "An empty box is three rows as well, not one. The place's dim sentence is on the first row and the other two are held open under it, so the one thing on the screen a person types into is a block they can see BEFORE they have typed anything — somebody who cannot find the box has nothing to type into it. It also means the foot does not move on the first keystroke, where a box that jumped from one row to three would shift the list up under the hand reaching for it. The resting rows are the box's silhouette and not its surface: the press span stays empty, so a click on them falls through to the place underneath exactly as a click on the resting row always has." - - "On a frame under about thirteen rows the box is one row again. Three held-open rows over a four-row head and a three-row foot left the list one row, which is the content the person came for giving way to the emptiness around the box. `placeBoxFloor` takes the floor only while the body keeps `placeBodyFloor` rows, and the same answer feeds the height the foot reserves and the rows it draws, so the two can never disagree." + - "The floor is spent out of rows the frame has OVER an eighty by twenty-four terminal, never out of that frame's own body — so it appears at twenty-six rows and taller and below that the box is the single row it has always been. Held open unconditionally it cost three rows everywhere, and at twenty-four those three are not spare: home dropped a panel off the bottom of its column, an empty place drew its rule where its whisper had been, and the rail's standing section was squeezed out by a long roster. Anything that remembers this as a height the box is always three rows at is wrong." + - "The composer's floor is the CONVERSATION'S floor too, not home's alone. The chat's foot and a place's foot are the same rows, and `esc` between them may not move any of them — a law that was broken and fixed once already. `boxFloor` is asked by the chat's `inputBlock`, which its height and its drawing both go through, and by every place's frame. On a frame tall enough for it the conversation's transcript is two rows shorter than it was." --- `aforge engine --stop-all` is here too, because it is the other half of the same diff --git a/internal/tui3/pages.go b/internal/tui3/pages.go index eb0dea18fd..dea60cfd7e 100644 --- a/internal/tui3/pages.go +++ b/internal/tui3/pages.go @@ -1639,22 +1639,32 @@ const ( // row by row and TestEveryPlaceSpendsTheSameHeadAndFoot reads it back). const placeFootRows = 3 + homeDraftFloor -// placeBodyFloor is the fewest rows of list a place is drawn with before the -// composer gives its own height back. Three is a row to stand on, a row above -// it and a row below it, which is the least that reads as a list at all. -const placeBodyFloor = 3 +// placeSmallestFrame is the shortest terminal this surface is laid out for, and +// the height nearly every law in this package is stated at. Eighty by +// twenty-four is not a guess: it is the size the design's own screens are drawn +// at and the size a frame has to survive without losing anything a person came +// for. +const placeSmallestFrame = 24 // boxFloor is how many rows the composer occupies, and it is the same number -// typed in or not ([homeDraftFloor]) — in the conversation and on every place -// — except on a frame with no room for it. -// -// A THREE-ROW BOX ON AN EIGHT-ROW TERMINAL IS THE LIST GONE. The foot already -// spends a blank, a rule and a hint; holding three more open on top of a -// four-row head leaves one body row, and the list a person came to read gives -// way to the emptiness around the box. So the floor is taken only while the -// body keeps [placeBodyFloor] rows under it, and a frame too short falls back to -// the single row the box has always drawn — the box stays usable, and what gives -// way is the space around it rather than the content above it. +// typed in or not ([homeDraftFloor]) — in the conversation and on every place — +// on any frame with rows to spare for it. +// +// THE FLOOR IS SPENT OUT OF ROOM THE FRAME HAS OVER THE SMALLEST ONE, NEVER OUT +// OF THE SMALLEST FRAME'S OWN BODY. Held open unconditionally it cost three +// rows everywhere, and at [placeSmallestFrame] those three are not spare: home +// dropped a whole panel off the bottom of its column, an empty place drew its +// rule where its whisper had been, and the rail's standing section was squeezed +// out by a long roster. A box nobody can miss is not worth the list they came +// to read, so a frame that cannot afford the floor draws the single row the box +// has always drawn. The box is just as usable; what gives way is the space +// around it. +// +// So the test is the BODY the floor would leave, against the body an eighty by +// twenty-four frame has under a one-row box — which works out at twenty-six rows +// and taller. It is written as that comparison rather than as the number, +// because the number is a consequence of the head and the foot and would be +// wrong the next time either of them moves. // // THE SAME ANSWER FEEDS THE HEIGHT AND THE DRAWING, so the rows a foot reserves // and the rows it then adds can never disagree. That is not tidiness: they are @@ -1668,7 +1678,11 @@ const placeBodyFloor = 3 // sizes through one app, and a floor decided from the window would be the wrong // floor for every frame but the last. func boxFloor(height int) int { - if height-(placeHeadRows+placeFootRows) < placeBodyFloor { + // The body the smallest frame has under a box of one row: its height, less + // the head, less that foot — the blank, the rule, the one box row and the + // last line. + const spare = placeSmallestFrame - placeHeadRows - (placeFootRows - homeDraftFloor + 1) + if height-placeHeadRows-placeFootRows < spare { return 1 } return homeDraftFloor From f73190dceffda3621cdf11838e14c9306f47550e Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 12 Sep 2026 10:43:51 -0400 Subject: [PATCH 7/9] the foot's height has one door, and the laws that sliced it ask that door MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The foot was a constant while the box was one row on every frame. It stopped being one when the floor started depending on the height, and five laws that had written `- 3`, `- 4` or placeFootRows into their own slicing went on reading the rule as though it were a row of the body. placeFootRowsAt is that arithmetic, once, and each of them asks it. draftBlock allocates its block at its cap rather than at the rows it happens to hold, so the floor pads into room that is already there — every frame of a scroll had been paying a fresh slice for two empty strings, which the allocation law priced at five allocations a screen. narrowHome's fixture gains four rows: the law there is that the arrows reach every row of the column, and it proves nothing on a column with three. Co-Authored-By: Claude Opus 5 --- internal/tui3/depthfade_test.go | 8 +++++--- internal/tui3/head_test.go | 14 ++++++++------ internal/tui3/home_test.go | 6 ++++-- internal/tui3/homearrows_test.go | 6 +++++- internal/tui3/input.go | 7 ++++++- internal/tui3/pages.go | 13 +++++++++++++ internal/tui3/placelaws_test.go | 11 ++++++++--- internal/tui3/placeprose_test.go | 2 +- 8 files changed, 50 insertions(+), 17 deletions(-) diff --git a/internal/tui3/depthfade_test.go b/internal/tui3/depthfade_test.go index d2e7ac6f92..413b496254 100644 --- a/internal/tui3/depthfade_test.go +++ b/internal/tui3/depthfade_test.go @@ -136,9 +136,11 @@ func fadeWord(i int) string { return strings.Repeat("i", i%7+1) + itoa(i) } func historyRows(a *app) []string { width, height := a.size() lines, _, _, _ := a.taskSheetFrame(width, height) - // The foot the router draws is four rows on every place, filtering or not: - // the note rides the rule (placebodies.go's [placeNoteRule]). - return lines[placeHeadRows : len(lines)-placeFootRows] + // The foot the router draws is the same rows on every place, filtering or + // not — the note rides the rule (placebodies.go's [placeNoteRule]) — and how + // many of them the composer is worth depends on the height ([boxFloor]), so + // the frame's own door is asked for the count. + return lines[placeHeadRows : len(lines)-placeFootRowsAt(height)] } // THE TAIL OF THE RECORD FADES AND ITS HEAD DOES NOT. A page holding two hundred diff --git a/internal/tui3/head_test.go b/internal/tui3/head_test.go index c9248b626e..a459f0bd09 100644 --- a/internal/tui3/head_test.go +++ b/internal/tui3/head_test.go @@ -170,11 +170,13 @@ func TestWalkingBetweenAChatAndThePlacesMovesNothingAtTheFoot(t *testing.T) { a := headLab(t) for _, size := range headFrameSizes { a.width, a.height = size.w, size.h - // The box row is the row the PROMPT is on, which is the block's first of - // [homeDraftFloor] ([footOf] reads it back that way), so it stands as far - // above the last row as the floor is deep. - want := footEdges{rule: size.h - placeFootRows + 1, - box: size.h - 1 - homeDraftFloor, status: size.h - 1} + // The box row is the row the PROMPT is on, which is the block's first + // ([footOf] reads it back that way), so it stands as far above the last + // row as the floor this height can afford is deep — asked of the frame's + // own door rather than written out again here. + floor := boxFloor(size.h) + want := footEdges{rule: size.h - placeFootRowsAt(size.h) + 1, + box: size.h - 1 - floor, status: size.h - 1} for _, to := range []page{pageNone, pageHome, pageTasks, pageNone} { if to == pageNone { a.showPage(pageNone) @@ -185,7 +187,7 @@ func TestWalkingBetweenAChatAndThePlacesMovesNothingAtTheFoot(t *testing.T) { rows := strings.Split(plain(frame(a)), "\n") if got := footOf(rows); got != want || strings.TrimSpace(rows[want.rule-1]) != "" { t.Fatalf("at %dx%d %s puts its foot at %+v, and every frame puts it at %+v under a blank:\n%s", - size.w, size.h, pageName(to), got, want, strings.Join(rows[len(rows)-placeFootRows-1:], "\n")) + size.w, size.h, pageName(to), got, want, strings.Join(rows[len(rows)-placeFootRowsAt(size.h)-1:], "\n")) } } } diff --git a/internal/tui3/home_test.go b/internal/tui3/home_test.go index 76cb4be096..ff6351c4cf 100644 --- a/internal/tui3/home_test.go +++ b/internal/tui3/home_test.go @@ -3100,8 +3100,10 @@ func TestTheListIsPaddedOffTheFoot(t *testing.T) { width, h := a.size() lines, _, _, _ := a.homeFrame(width, h) // The foot is the rule, the box and the hint; the row above it is the - // padding, and it is empty whatever the list did. - pad := len(lines) - 4 + // padding, and it is empty whatever the list did. How tall the box is + // depends on the height ([boxFloor]), so the foot is asked rather than + // counted out here. + pad := len(lines) - placeFootRowsAt(h) if got := strings.TrimSpace(ansi.Strip(lines[pad])); got != "" { t.Fatalf("at height %d (typed %v) the list touches the foot: row %d is %q\n%s", height, typed, pad, got, strings.Join(lines, "\n")) diff --git a/internal/tui3/homearrows_test.go b/internal/tui3/homearrows_test.go index e55ffe3d24..4b1e03ae3f 100644 --- a/internal/tui3/homearrows_test.go +++ b/internal/tui3/homearrows_test.go @@ -27,7 +27,11 @@ import ( func narrowHome(t *testing.T, lab *homeLab, standing string, width int) *app { t.Helper() a := lab.app(standing) - a.width, a.height = width, 30 + // Tall enough that the column still has rows to spare once the composer + // takes its floor ([boxFloor] hands it back under twenty-six): the law here + // is that ↑ and ↓ reach every row there is, and it proves nothing on a + // column with three of them. + a.width, a.height = width, 34 a.openHome() // The frame settles the tier before a key is read, exactly as a paint does // (home.go's [app.homeFrame] rebuilds the column when it crosses diff --git a/internal/tui3/input.go b/internal/tui3/input.go index a04271bc5b..1d7a4307e9 100644 --- a/internal/tui3/input.go +++ b/internal/tui3/input.go @@ -1720,7 +1720,12 @@ func draftBlockWithTags(e *editor, pal palette, width, maxRows int, hint, lead s caretColumn := caretColumnIn(e, segments[caretRow]) end := min(top+maxRows, len(segments)) - out := make([]string, 0, end-top) + // The block is allocated at its CAP and not at the rows it happens to hold, + // so the floor [app.inputBlock] holds it to is padding into room that is + // already there. Allocated at `end-top` instead, every frame of a scroll + // paid a fresh slice for two empty strings — which the allocation law in + // inputsmooth_test.go priced at five allocations a screen. + out := make([]string, 0, maxRows) // Every row after the first is indented to where the text starts, segment // included: a continuation that began under the segment would be a wrapped // sentence with a step in its left margin. diff --git a/internal/tui3/pages.go b/internal/tui3/pages.go index dea60cfd7e..af6f35f34b 100644 --- a/internal/tui3/pages.go +++ b/internal/tui3/pages.go @@ -1646,6 +1646,19 @@ const placeFootRows = 3 + homeDraftFloor // for. const placeSmallestFrame = 24 +// placeFootRowsAt is [placeFootRows] on a frame of a given height: the same +// blank, the same rule and the same last line, over a box held to whatever +// floor that height can afford ([boxFloor]). +// +// IT EXISTS SO THAT NOTHING KEEPS A SECOND COPY OF THIS ARITHMETIC. The foot +// was a constant while the box was one row on every frame; it stopped being one +// the moment the floor started depending on the height, and four laws that had +// quietly written `- 3` or `- 4` into their own slicing went on reading the +// rule as though it were a row of the body. +func placeFootRowsAt(height int) int { + return placeFootRows - homeDraftFloor + boxFloor(height) +} + // boxFloor is how many rows the composer occupies, and it is the same number // typed in or not ([homeDraftFloor]) — in the conversation and on every place — // on any frame with rows to spare for it. diff --git a/internal/tui3/placelaws_test.go b/internal/tui3/placelaws_test.go index 42c0139928..9a1ca18137 100644 --- a/internal/tui3/placelaws_test.go +++ b/internal/tui3/placelaws_test.go @@ -317,8 +317,12 @@ func TestEveryPlaceSpendsTheSameHeadAndFoot(t *testing.T) { want := map[[2]int]edges{} for _, size := range sizes { height := size[1] + // The box is as many rows as this height can afford ([boxFloor]), and the + // rule sits directly over the first of them, so both edges are derived + // from the frame's own door rather than counted out again here. + floor := boxFloor(height) want[size] = edges{bar: placeTabRow, headRule: 2, blank: placeHeadRows - 1, - footRule: height - placeFootRows + 1, box: height - 1 - homeDraftFloor, hint: height - 1} + footRule: height - placeFootRowsAt(height) + 1, box: height - 1 - floor, hint: height - 1} } for _, lab := range labs { for _, size := range sizes { @@ -341,10 +345,11 @@ func TestEveryPlaceSpendsTheSameHeadAndFoot(t *testing.T) { // [homeDraftFloor] rows and its FIRST row is the one carrying the // prompt, so the rule sits one above that and the rows between the // prompt and the hint are the composer's own. - if at := len(rows) - 2 - homeDraftFloor; at >= 0 && strings.HasPrefix(rows[at], "─") { + floor := boxFloor(size[1]) + if at := len(rows) - 2 - floor; at >= 0 && strings.HasPrefix(rows[at], "─") { got.footRule = at } - if at := len(rows) - 1 - homeDraftFloor; at >= 0 && strings.HasPrefix(rows[at], " "+prompt) { + if at := len(rows) - 1 - floor; at >= 0 && strings.HasPrefix(rows[at], " "+prompt) { got.box = at } if got != want[size] { diff --git a/internal/tui3/placeprose_test.go b/internal/tui3/placeprose_test.go index d284d1b324..836203241d 100644 --- a/internal/tui3/placeprose_test.go +++ b/internal/tui3/placeprose_test.go @@ -142,7 +142,7 @@ func TestEveryEmptyPlaceDrawsItsHeadingAndOneWhisper(t *testing.T) { a.width, a.height = size[0], size[1] lines, _, _, _ := a.placeDraw(placeFor(lab.id), a.width, a.height) body := make([]string, 0, len(lines)) - for _, line := range lines[placeHeadRows : len(lines)-3] { + for _, line := range lines[placeHeadRows : len(lines)-placeFootRowsAt(size[1])+1] { body = append(body, strings.TrimRight(plain(line), " ")) } want := placeWhisperLines(lab.id, a.width, newPalette(tokens.NoColor, false)) From 2026654f3b94d418174b2caa8d9d23cbfc62c410 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 12 Sep 2026 11:31:15 -0400 Subject: [PATCH 8/9] manual: the sweep's page stops competing for the vanished reply's question `--stop-all`'s section described what it does to a conversation in the same words the stop-cause sentence uses, so "the engine holding this conversation was stopped" retrieved a page about clearing daemons rather than the page that says what that sentence means. The probe caught it only on the merge: the section and the dev-side wording each read fine alone. The page says what the sweep does in the sweep's own terms and points at the page that owns the question. Co-Authored-By: Claude Opus 5 --- internal/manual/chat/staying-on-that-machine.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/internal/manual/chat/staying-on-that-machine.md b/internal/manual/chat/staying-on-that-machine.md index c933bbbd0b..591db5ba75 100644 --- a/internal/manual/chat/staying-on-that-machine.md +++ b/internal/manual/chat/staying-on-that-machine.md @@ -419,10 +419,13 @@ refuses does not stop the sweep — the whole point is the folder you did not kn failure on the third of five must not hide the fourth — and every refusal is reported together at the end. -This is the blunt instrument and it is safe to reach for: an engine that is stopped closes -its conversations and flushes their transcripts first, and a turn it catches keeps its -partial reply. What it costs you is that every conversation on this machine comes back on -the next connection rather than staying warm. +This is the blunt instrument and it is safe to reach for: a host let go of this way writes +every transcript out before it goes, and a turn the sweep catches keeps the part of its +reply that had arrived. What it costs you is that everything on this machine comes back +cold on the next connection rather than staying warm. + +If what you are actually chasing is a reply that stopped and said so on screen, this is +not the page — *Models and cost* has the sentence you read and what each of them means. ## Rebuilt aforge but your conversation was still on the old engine — how aforge tells you From 4979052e22b3b0b19ddc1db552c100bd9df7775c Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 12 Sep 2026 11:31:56 -0400 Subject: [PATCH 9/9] manual: the vanished reply's heading carries the sentence a person read "the engine holding this conversation was stopped" is what is on the screen, and the heading that explains it said "the engine was stopped". That was enough while nothing else on the corpus wanted those words; two new sections about stopping engines on purpose took the four results the search returns, and the page that says what the sentence MEANS fell off the end of them. So the heading is spelled the way the sentence is. Retrieval is the feature: a page that is complete, correct and unreachable is a page the chat talks over. Co-Authored-By: Claude Opus 5 --- internal/manual/chat/models-and-cost.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index f96a1dda10..66f5ef7b23 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -1621,7 +1621,7 @@ with no answer, no error, no note and an idle status line, and there was no way — on the screen, in the transcript, or in the log — to tell your own stop from a second window taking the conversation over. -## It said nobody was left watching but I was sitting right here — the reply stopped, bash cancelled, which window did it think had gone, the engine was stopped +## It said nobody was left watching but I was sitting right here — the reply stopped, bash cancelled, which window did it think had gone, the engine holding this conversation was stopped `nobody was left watching this conversation, so the reply stopped — ask again to pick it up` is only what you read when the conversation was let go of because nobody was watching it. A goodbye of your own says `the reply stopped when this conversation was left — ask again to pick it up`. An engine stopped on somebody's word while a window is still in the room says `the engine holding this conversation was stopped, so the reply stopped — ask again to pick it up`.