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/1000-home-input-three-rows.md b/docs/changes/unreleased/1000-home-input-three-rows.md new file mode 100644 index 0000000000..9bbabef06e --- /dev/null +++ b/docs/changes/unreleased/1000-home-input-three-rows.md @@ -0,0 +1,26 @@ +--- +kind: changed +title: Home's box is three rows tall, typed in or not, and one command clears every engine +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." + - "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." + - "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 +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 +demo home, where the caret lands on the last character of a two-row draft. 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 e2c2c73f90..0bc25229f4 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -706,11 +706,24 @@ 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 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. + +**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 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`. diff --git a/internal/manual/chat/staying-on-that-machine.md b/internal/manual/chat/staying-on-that-machine.md index 532c52904f..591db5ba75 100644 --- a/internal/manual/chat/staying-on-that-machine.md +++ b/internal/manual/chat/staying-on-that-machine.md @@ -389,9 +389,43 @@ 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: 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 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 74d7def0f9..a459f0bd09 100644 --- a/internal/tui3/head_test.go +++ b/internal/tui3/head_test.go @@ -170,7 +170,13 @@ 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 + // ([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) @@ -181,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.go b/internal/tui3/home.go index f8578221e7..c557fb2aa6 100644 --- a/internal/tui3/home.go +++ b/internal/tui3/home.go @@ -254,6 +254,51 @@ const ( // the caret exactly as the chat box's does (input.go's draftBlock). const homeDraftRows = 3 +// 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 +// 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 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 // is spelled here. const ( 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 88fb25e3bb..1d7a4307e9 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 @@ -1694,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 da7efe9b05..af6f35f34b 100644 --- a/internal/tui3/pages.go +++ b/internal/tui3/pages.go @@ -1186,9 +1186,30 @@ func placeFrameWithBar(a *app, width, height int, case box != nil && !box.empty() && !a.placeBoxOnBody(): draftRows, draftCX, draftCY = draftBlock(box, pal, width-2, homeDraftRows, "", "") } + // 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 + // 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 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 // FOOT, and they are asked for separately because they are two different @@ -1357,6 +1378,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 < boxFloor(height); row++ { + add("", nil) + } // AT REST THERE IS NOTHING TO TYPE INTO, so the caret is hidden rather // than left blinking at the frame's origin. The moment a character lands // the box stops being empty and the caret comes back, in the box. @@ -1601,6 +1630,77 @@ 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 + +// 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 + +// 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. +// +// 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 +// 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 boxFloor(height int) int { + // 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 +} + // 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 2ca848cbea..9a1ca18137 100644 --- a/internal/tui3/placelaws_test.go +++ b/internal/tui3/placelaws_test.go @@ -297,9 +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 and the hint. -const placeFootRows = 4 +// [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 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 } @@ -308,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 - 2, hint: height - 1} + footRule: height - placeFootRowsAt(height) + 1, box: height - 1 - floor, hint: height - 1} } for _, lab := range labs { for _, size := range sizes { @@ -327,11 +340,17 @@ 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. + floor := boxFloor(size[1]) + if at := len(rows) - 2 - floor; 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) - 1 - floor; 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", 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))