From 2e2b5ed67eb993e19aa14669fae9c2053a1baaae Mon Sep 17 00:00:00 2001 From: ZeroPoint95 Date: Mon, 21 Sep 2026 19:35:34 -0400 Subject: [PATCH 01/39] manual: space space no longer opens home, esc does The surface stopped treating two spaces over an empty box as the home door on 2026-09-17 (TestDoubleSpaceNoLongerNavigates pins it, and home's tests press esc), but five passages across keys, home, places and running-on-another-machine still taught the old gesture. They now say esc, and the places section over --host keeps the old words in its heading so the question still finds it. Co-Authored-By: Claude Fable 5.1 --- internal/manual/chat/home.md | 6 +++--- internal/manual/chat/keys.md | 2 +- internal/manual/chat/places.md | 8 +++++--- internal/manual/chat/running-on-another-machine.md | 2 +- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index a41af8ecbb..1be4e0e619 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -496,7 +496,7 @@ and says `here`. ## Where the cursor starts on home — on my previous chat — and where the first down arrow goes -**Opening home with `space` `space`, `/home` or `alt+1` puts the cursor on the conversation +**Opening home with `esc`, `/home` or `alt+1` puts the cursor on the conversation this window was in before the one in front** — the most recent other one on this window's own tab stack — so going back is `enter`. A window that has held only one conversation has no "before", and the cursor is on its own row in the conversation list, which says `here`. @@ -670,7 +670,7 @@ Four ways, and each of them is you saying which conversation you mean: | `codeaf chat --session ` | that conversation, no home | | `codeaf resume` | the session picker, no home | | `codeaf chat --once "text"` | replies printed with no surface; one reply normally, or every landing-woken reply when `--yolo` has a budget | -| `codeaf --host ` | the far machine's session, no greeting — `space` `space` opens that machine's home | +| `codeaf --host ` | the far machine's session, no greeting — `esc` opens that machine's home | And on a machine with only one conversation — a first run — home does not greet you. There is no setting for this and no flag to turn it off: whether home greets you follows @@ -1054,7 +1054,7 @@ All of the following holds over the ordinary engine socket, `--host`, `--at` and ## How do I switch to my other chat — and is it still running -**`tab` with an empty message box**, or `space` `space` and then `enter` — home opens with +**`tab` with an empty message box**, or `esc` and then `enter` — home opens with the cursor already on the chat you were in before this one. Either goes straight to it; nothing is reopened and nothing is replayed from cold that does not have to be. diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index 96ccbad231..57c46aa4c9 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -591,7 +591,7 @@ key arrives as ordinary `enter` and the message steers instead. | `alt+e` | Walk this conversation's thinking rung one step: auto → low → medium → high → xhigh → max, and back to auto. Works with a sentence half typed. On home and every other place it walks the rung of the **next** conversation instead — the effort word after the model’s colon on home’s seam | | `alt+a` | Walk what this conversation runs without asking one stop: asks → guardian → YOLO → asks. Never lands on `refuses`. Works with a sentence half typed; over `--host` it says the far machine's rules decide. On home and every other place it walks the gate of the **next** conversation — the `◇` cell on the rule above that box — and that pin is spent by the conversation that uses it | | `ctrl+.` | Open the sessions place (`/history`) — every task this machine has run, across every project and every session; type to filter it. It opens on a machine that has run nothing too, and the page says what tasks are | -| `space` `space` | On an **empty** box: open home (`/home`) — every project and conversation on the machine the session runs on, and an empty home on a fresh one. Does nothing when the box has words in it | +| `space` `space` | Two spaces, nothing more. This used to open home over an empty box; that door closed on 2026-09-17, and `esc` is the way home — see *Escape, esc, back, and getting home without stopping work* | | `ctrl+l` | Jump back to the live edge of the conversation | | `ctrl+t` | Start a **new chat** — the same start page the `+` at the end of the tab strip opens. Nothing is created until you send the first message, `esc` comes back, and the conversation you were in keeps its draft, its attachments and its work. On a home row it starts the fresh chat in that row's own folder, the same door as `enter` on a `projects` row | | `ctrl+w` | **Close this tab** — the same thing the `✕` on it does. Selects the last-used remaining tab, or Home if none remain. Drafts are kept, and the conversation keeps running; a tab with work in it asks `keep running` / `stop work` / `cancel` first | diff --git a/internal/manual/chat/places.md b/internal/manual/chat/places.md index bc61feaaf2..81b67f0db9 100644 --- a/internal/manual/chat/places.md +++ b/internal/manual/chat/places.md @@ -796,14 +796,16 @@ afternoon. place segment and the legend under the box already carry. On a local session it is not there at all: a machine name is worth a word only when there is more than one machine in play. -## Why is home empty over ssh when I connect to another machine — Escape over --host +## Why is home empty over ssh when I connect to another machine — Escape, once space space, over --host **It is not empty any more, and this is the answer if you have seen it be.** -`space` `space` over `--host` opens the home of the machine your session runs on: its +`esc` over `--host` opens the home of the machine your session runs on: its projects, its conversations, and what each of those ran. `enter` on a row opens that conversation beside the one you are in — the engine gives it a connection of its own and -the chat you came from keeps running, the same door `codeaf resume` uses locally. +the chat you came from keeps running, the same door `codeaf resume` uses locally. Two +spaces over an empty box used to be this door as well; since 2026-09-17 `space` `space` +types two spaces and nothing more, here and locally, and `esc` is the key. It used to draw **one dim line** where the rows would be — `home shows this machine's projects, and this session is on another` — because the projects diff --git a/internal/manual/chat/running-on-another-machine.md b/internal/manual/chat/running-on-another-machine.md index 97fbba54a4..259a71e1c6 100644 --- a/internal/manual/chat/running-on-another-machine.md +++ b/internal/manual/chat/running-on-another-machine.md @@ -268,7 +268,7 @@ on a remote path — expect to see the full path. Yes, and they show **the far machine's**. -`space` `space` opens the home of the machine your session runs on: its projects, its +`esc` opens the home of the machine your session runs on: its projects, its conversations, what each of them ran, and what keeps an eye on it. `enter` on a row opens that conversation beside the one you are in — the engine gives it a connection of its own and the chat you came from keeps running, the same door `codeaf resume` uses locally. The right end of the tab bar reads `on ` so you can From d8623373f896de5582fcf91b69370083d9fc51c1 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 Date: Mon, 21 Sep 2026 23:27:11 -0400 Subject: [PATCH 02/39] chat: home has a hint row, and the tip table grows to thirty on two boxes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Home never drew an earned tip: the picker refused the page outright and its gap was measured in turns home does not have. The blank directly above the rule over home's box is now the tip row — drawn only over an idle box with no list, layer or exchange up, one cell in, dim, in the key-then-what-it-does grammar the conversation's foot keeps. The table is one table for both boxes. Each hint row says where it may draw (the conversation's foot, home's row, or both), retirement is shared through the same notices.json ledger, and the surface refuses to build on a note row that names a box or a row filed under home's slot. Twenty-three lines join the seven that were here, each retired by a real seam: /ask and alt+enter, /task, ctrl+enter, ctrl+r, the @ list, /attach and /image, /folder, /export, the model list, /crew, /budget, the spend and search places, steering and ctrl+q, ctrl+t, alt+1..7, /remember, /subharness, /connect, and a media call beginning. `/ shows every command` retires: both feet say `/ commands`. Home rotates rather than ranks: every eligible tip has its turn in table order, moving on every visit and every two minutes at rest on the beat home already runs, and a tip that has come round six times is taken as read. A board test pins the ring, a lab test pins the row over the frame, and a new gate holds the hints page to every line in the table word for word. Co-Authored-By: Claude Fable 5.1 --- internal/manual/chat/hints-and-tips.md | 161 +++++++-- internal/manual/chat/home.md | 11 + internal/manual/chat_test.go | 4 + internal/tui3/app.go | 10 + internal/tui3/attach.go | 2 + internal/tui3/budget.go | 1 + internal/tui3/chatstart.go | 1 + internal/tui3/connectpanel.go | 1 + internal/tui3/crew.go | 1 + internal/tui3/folderplace.go | 7 + internal/tui3/followup.go | 1 + internal/tui3/homedraft.go | 1 + internal/tui3/homeexchange.go | 2 + internal/tui3/hometip_test.go | 278 ++++++++++++++++ internal/tui3/memory.go | 1 + internal/tui3/notice.go | 440 +++++++++++++++++++++++-- internal/tui3/notice_test.go | 44 ++- internal/tui3/pages.go | 22 +- internal/tui3/palette.go | 1 + internal/tui3/placekeys.go | 1 + internal/tui3/spellout.go | 3 + internal/tui3/steer.go | 1 + internal/tui3/taskcommand.go | 2 + 23 files changed, 936 insertions(+), 60 deletions(-) create mode 100644 internal/tui3/hometip_test.go diff --git a/internal/manual/chat/hints-and-tips.md b/internal/manual/chat/hints-and-tips.md index a8a4452472..edd5c9df52 100644 --- a/internal/manual/chat/hints-and-tips.md +++ b/internal/manual/chat/hints-and-tips.md @@ -16,55 +16,148 @@ open a list, or an answer starts, the slot goes back to the keys for that state; returns when things are quiet again. A tip never takes a row of its own and never blocks a keystroke — it is the keys row, which is on the screen anyway. +Home has a row of its own for the same tips, directly above the rule over its box — see +*The dim sentence above the rule on home*. + +## The dim sentence above the rule on home — the tip on home, what is that line over the box + +On home the tip is the dim row **directly above the rule** over the message box — the blank +that separates the list from the rule, with one sentence written into it. It reads the way +every tip does: the key or the command first, then what it does — `/ask answers right here +without opening a conversation`, `alt+1 to alt+7 jump straight to a place`. The keys row at +the very foot of home is not a tip and never changes: it names the row's options and the +draft's chords (`→ options · alt+p project · alt+e effort · alt+a approvals · / commands`). + +It is there only while home is at rest: the box empty, no command list or model list up, no +task or question open in the right pane. Type a letter and the row is blank again; clear +the box and the tip is back. The row is the same row whether or not a tip is on it, so the +list above never moves. + +**It changes on every visit and every two minutes.** Each time you come to home — `esc` +from a conversation, `/home`, `alt+1`, `tab` — the row moves on to the next tip that is +true for you, in a fixed order, round and round. Left at rest, it moves on by itself after +two minutes; a home nobody is looking at (the box being typed into, a list up) does not +age, because what has not been read has not been shown. On a Mac the row says `opt` where +the table below says `alt`, exactly as the keys row does. + ## Why did the hint disappear — each tip retires once you use what it teaches Every tip is earned and then spent. It appears the first time it becomes relevant — the first task you start, the first long answer, the first time a conversation passes half its -context window — and it goes away for good the first time you do the thing it names. Open -the task page once and `ctrl+. sees every task this project has run` never comes back; run -`/compact` once and the compact tip is retired. - -A tip you never act on is not shown forever either. Once it has been shown in three separate -sessions it is taken as read and retires by itself. Between tips there is always a gap of a -couple of turns, so a busy first session does not turn the border into a slideshow. +context window, or simply the first time home is open — and it goes away for good the first +time you do the thing it names. Open the task page once and `ctrl+. sees every task this +project has run` never comes back; run `/compact` once and the compact tip is retired. A tip +retired from either box is retired from both: opening the model list on home retires +`/model lists every model` in every conversation as well. + +A tip you never act on is not shown forever either. In a conversation, once it has been +shown in three separate sessions it is taken as read and retires by itself; on home, where +the row turns over faster, a tip retires after six turns of the rotation. Between tips in a +conversation there is always a gap of a couple of turns, so a busy first session does not +turn the border into a slideshow. This is remembered per profile, in a small file called `notices.json` beside `config.json` -in your codeaf profile directory. Deleting that file brings every tip back once; nothing else -is in it. +in your codeaf profile directory. Retiring is permanent: turning hints off and on does not +bring a retired tip back. Deleting that file brings every tip back once; nothing else is in +it. ## Every hint codeaf can show, and what makes each one go away -There are eight at the moment. Each one names the moment it first appears and the gesture -that retires it. - -- `/ shows every command` — after your first turn ends. Retired when you open the command - list by typing `/`. -- `ctrl+. sees every task this project has run` — after the first task starts. Retired when - you open the task page, by `ctrl+.` or `/history`. -- `/rewind takes back an earlier message` — after an answer of about 1,500 characters or more. - Retired the first time a rewind lands, from `/rewind`. -- `/compact summarizes the conversation now` — when the conversation passes half its +There are thirty. Each one says where it can appear — in a conversation's keys row, on +home's row above the rule, or both — the moment it first appears, and the gesture that +retires it. The list is the program's own table (the surface refuses to build if the two +disagree), so a tip you saw is on it word for word. + +**Starting work** + +- `/ask answers right here without opening a conversation` — home only, whenever home's + ask door is there. Retired the first time `/ask` or `alt+enter` sends something from home. +- `alt+enter sends what you typed off as a task` — home only, on the same terms and retired + by the same gesture. +- `/task starts work you can walk away from` — conversation only, after the first exchange. + Retired when `/task` is typed, bare or with a brief. +- `ctrl+enter sends your message as something to keep true` — both. Retired when a standing + order is made or the standing page opened. +- `/standing keeps something always true` — both, once this directory has three or more + earlier conversations. Retired by the same gesture; it is the quietest and yields to every + other in a conversation. +- `ctrl+r spells out what your sentence is taken to mean` — both, where the chord works. + Retired the first time you press it. + +**Files and context** + +- `@ completes a file, a folder or a task into your message` — both. Retired when the `@` + list opens. +- `/attach sends a file along with your message` — both. Retired when a file goes on the + tray by path or the file browser opens. +- `/image attaches a picture, or paste a screenshot in` — both. Retired by the same gesture. +- `/folder picks the folder codeaf works in` — both. Retired when the folder chooser opens, + from a conversation or aimed at home's target. +- `/export writes this whole conversation to a file` — conversation only, after two + exchanges. Retired when an export lands. +- `/files finds everything made for you` — both, after the first export writes a file. + Retired when you run `/files`. + +**Models, thinking and cost** + +- `/model lists every model, /model switches at once` — both. Retired when the model + list opens, over a conversation or over home's draft. +- `/crew sets the models codeaf uses on its own behalf` — both. Retired when `/crew` + answers, bare or with a preset. +- `/budget caps what today may cost` — both. Retired when `/budget` answers. +- `alt+3 shows what this machine has spent, by the day` — both. Retired when the spend + place opens by any door. +- `/cost says what this conversation has spent` — both, once the conversation has spent + about ten cents. Retired when you run `/cost`. +- `/compact summarizes the conversation now` — both, when the conversation passes half its context window. Retired when a `/compact` finishes. -- `/files finds everything made for you` — after the first `/export` writes a file. Retired - when you run `/files`. -- `/resume opens an earlier conversation` — when you start in a directory that already has - a conversation. Retired when you run `/resume`. -- `/cost says what this conversation has spent` — once the conversation has spent about - ten cents. Retired when you run `/cost`. -- `/standing keeps something always true` — once this directory has three or more earlier - conversations. Retired when you open `/standing` or make a standing order. It is the - quietest of the eight and yields to every other. - -When two are relevant at once the more useful one wins — the compact tip over the cost tip, -the cost tip over the task page tip — and the other waits its turn. + +**Steering a running answer** + +- `enter while an answer is coming stops it and steers` — conversation only, after the + first exchange. Retired the first time you steer. +- `ctrl+q queues this message for after the current turn` — conversation only, after the + first exchange. Retired the first time you queue one. +- `/rewind takes back an earlier message` — both, after an answer of about 1,500 characters + or more. Retired the first time a rewind lands. + +**Moving around** + +- `ctrl+t starts a fresh chat in this folder` — both. Retired when the new-chat page opens. +- `alt+1 to alt+7 jump straight to a place` — both. Retired the first time a place chord + reaches one. +- `ctrl+. sees every task this project has run` — both, after the first task starts. + Retired when you open the task page, by `ctrl+.` or `/history`. +- `/resume opens an earlier conversation` — both, when you start in a directory that + already has a conversation. Retired when you run `/resume`. + +**Memory, accounts and the rest** + +- `/remember keeps one thing across conversations` — both. Retired when `/remember` is + typed. +- `/search finds anything ever said on this machine` — both. Retired when the search place + opens by any door. +- `/subharness lists the programs you can run` — both. Retired when `/subharness` is typed, + bare or with a name. +- `/connect links Google, Slack or another model service` — both. Retired when the connect + panel is reached for. +- `ask for a picture, a voiceover, music or a video` — both. Retired the first time the + session begins making one. + +When two are relevant at once in a conversation the more useful one wins — the compact tip +over the cost tip, the cost tip over the task page tip — and the other waits its turn. On +home nothing wins: every tip that is true for you has its turn, in the order above. + +`/ shows every command` used to be one of these. It is gone because both keys rows now say +`/ commands` outright, so there was nothing left to teach. ## Turn off hints — stop showing tips, disable the hints Open the settings panel with `/settings` (or `ctrl+,`), go to the **Display** tab, and flip the **hints** row off. Enter or space toggles it. The change lands at the end of the next -turn. Off silences the tips and the what's-new lines together; it does not touch the keys -the slot names for a live state — `ctrl+c interrupt` and the rest are not hints and cannot be -turned off. +turn. Off silences the tips — in the conversation's keys row and on home's row alike — and +the what's-new lines together; it does not touch the keys the slot names for a live state — +`ctrl+c interrupt` and the rest are not hints and cannot be turned off. Turning the row back on shows whatever is due. Tips you had already retired stay retired. diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index 1be4e0e619..50d3dc9860 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -1376,6 +1376,17 @@ The double-space binding has been removed. Spaces type normally in message boxes `/home` and `alt+1` (`opt+1` on a Mac) also open Home. Open a conversation row or use `alt+k` to return to a conversation; Escape does not leave Home. +## The dim sentence above the rule on home — what is that tip over the box, why did it change + +The one dim line directly above the rule over home's box is a **tip**: one sentence naming a +key or a command you have not used yet, and what it does — `/ask answers right here without +opening a conversation`, `alt+1 to alt+7 jump straight to a place`. It is drawn only while +the box is empty and nothing else is up, it moves on to the next tip every time you come to +home and every two minutes at rest, and each tip goes away for good the first time you do +what it names. The keys row at the very foot is not a tip and never changes. The whole list, +what makes each one appear and disappear, and the **hints** row on the Display tab that +turns them off, are on the *hints and tips* page. + ## What does pressing space twice do — space space does nothing now Two spaces are ordinary text. The old Home shortcut is removed. Use `esc` to back out diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index 3e809f42db..a51b923863 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -2372,6 +2372,10 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { {"how do I turn off hints", "hints-and-tips"}, {"stop showing tips", "hints-and-tips"}, {"what is a news line", "hints-and-tips"}, + {"what is the dim sentence above the rule on home", "hints-and-tips"}, + {"the tip on home changed by itself", "hints-and-tips"}, + {"every hint codeaf can show", "hints-and-tips"}, + {"is a retired tip gone for good", "hints-and-tips"}, // The wave that made the places follow the session's machine. These are // the owner's own sentences, from the report that started it: they diff --git a/internal/tui3/app.go b/internal/tui3/app.go index 6bfa1e9356..d91a544fa1 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -4503,6 +4503,8 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { // one (placecounts.go). if a.at(pageHome) { a.refreshPlaceCounts(a.now()) + // AND THE TIP ON HOME'S ROW AGES ON THE SAME BEAT (notice.go). + a.noticeHomeBeat() } return a, a.homeBeat(msg.gen) @@ -5257,6 +5259,11 @@ func (a *app) applyEvent(ev session.Event, lump bool) tea.Cmd { default: a.collapseThought() } + // A PICTURE, A VOICE, MUSIC OR FILM BEGINNING is the proof the person knows + // to ask for one (notice.go's [mediaTools]). + if ev.Kind == session.EventToolBegin && mediaTools[ev.Tool] { + a.noticeEvent(eventMediaAsked) + } // THE WAIT CLOCK IS ANCHORED HERE, on both edges, before anything else reads // it. The two lists below are the whole of what the surface knows about a // model request's life, and they are kept together so the pair cannot drift. @@ -7101,6 +7108,7 @@ func (a *app) slash(line string) tea.Cmd { return nil case "subharness": + a.noticeEvent(eventSubharnessOpened) // THE PROGRAMS THIS CONVERSATION CAN RUN, as a filterable list, and the // intake card behind each of them (subharness.go). Unlike /harness this // one DOES take a name: a subharness's name is its identity across the @@ -8428,6 +8436,8 @@ func (a *app) syncLists() tea.Cmd { was := a.comp.open a.comp.sync(&a.input) if a.comp.open && !was { + // The list coming up is the proof that `@` has been found (notice.go). + a.noticeEvent(eventAtOpened) // Both halves of the list are asked for at the same moment, and neither // waits for the other: the index is one small file and lands first, the // walk lands when it lands (taskmention.go, files.go). diff --git a/internal/tui3/attach.go b/internal/tui3/attach.go index aad3be7e66..a9ab1976db 100644 --- a/internal/tui3/attach.go +++ b/internal/tui3/attach.go @@ -276,6 +276,7 @@ func (a *app) removeChip(i int) { // not. Every refusal names the file, because "not an image" about a path the // person typed is a sentence they can act on and "could not attach" is not. func (a *app) attachPath(raw string) { + a.noticeEvent(eventAttached) raw = strings.TrimSpace(raw) if raw == "" { a.note("/image takes a path · try /image shot.png") @@ -314,6 +315,7 @@ func (a *app) attachPath(raw string) { // empty argument is a caller mistake and not a person's, and the refusal that // used to stand for it is gone rather than unreachable. func (a *app) attachFilePath(raw string) { + a.noticeEvent(eventAttached) raw = strings.TrimSpace(raw) if raw == "" { return diff --git a/internal/tui3/budget.go b/internal/tui3/budget.go index bff41e9eb0..613bdc518a 100644 --- a/internal/tui3/budget.go +++ b/internal/tui3/budget.go @@ -71,6 +71,7 @@ func budgetWords() string { // /budget plan 20 one row by name // /budget plan the tab, on that row func (a *app) budget(rest string) tea.Cmd { + a.noticeEvent(eventBudgetShown) rest = strings.TrimSpace(rest) if rest == "" { return a.openSpending(config.KeyDailyBudget) diff --git a/internal/tui3/chatstart.go b/internal/tui3/chatstart.go index 2eff13911f..e55f449026 100644 --- a/internal/tui3/chatstart.go +++ b/internal/tui3/chatstart.go @@ -178,6 +178,7 @@ func (a *app) startSay(word string) { // building a second one — a person leaning on a control is not asking for two of // what it makes. func (a *app) openChatStart() tea.Cmd { + a.noticeEvent(eventChatStarted) if a.startingChat() { // The page is already up. It keeps its words and its selection. a.touch() diff --git a/internal/tui3/connectpanel.go b/internal/tui3/connectpanel.go index b2df0eb115..1fc574bdad 100644 --- a/internal/tui3/connectpanel.go +++ b/internal/tui3/connectpanel.go @@ -468,6 +468,7 @@ func (p *connectPanel) draw(width, n int, pal palette, hover int) []string { // picker resolves its own: an account connected in another window an hour ago is // an account this list has to know about, and asking costs a read. func (a *app) openConnect() { + a.noticeEvent(eventConnectOpened) // IT STATES THE FACT RATHER THAN GOING MISSING (host.go). The command still // exists, still answers, and answers with the reason: a sign-in opens a // browser and waits on a loopback port, and over --host the browser is here diff --git a/internal/tui3/crew.go b/internal/tui3/crew.go index f9c63cfca0..0d7239cffb 100644 --- a/internal/tui3/crew.go +++ b/internal/tui3/crew.go @@ -31,6 +31,7 @@ import ( // runCrew is /crew: the three presets with the current one marked, or one applied. func (a *app) runCrew(arg string) { + a.noticeEvent(eventCrewShown) if a.hosted() { a.note(a.remoteProfileWord("the crew")) return diff --git a/internal/tui3/folderplace.go b/internal/tui3/folderplace.go index 1fcdace12d..73d892361e 100644 --- a/internal/tui3/folderplace.go +++ b/internal/tui3/folderplace.go @@ -239,6 +239,7 @@ func (a *app) openFolderPick(query string) tea.Cmd { // [folderRemoteWord]'s argument said about the target: the pin would name a // directory the next conversation cannot open. func (a *app) openTargetFolderPick(query string) tea.Cmd { + a.noticeEvent(eventFolderPicked) if a.hosted() { a.home.say(folderRemoteWord, "") return nil @@ -310,6 +311,12 @@ func (a *app) closeFolderSheet() tea.Cmd { // needs no folder door whatever. The sheet opens; a folder row on it then // refuses with the same sentence when it is confirmed (folderact.go). func (a *app) openContextPick(query string, folders bool) tea.Cmd { + // Either door found is a door learned, whatever the list answers (notice.go). + if folders { + a.noticeEvent(eventFolderPicked) + } else { + a.noticeEvent(eventAttached) + } // THE INTENT CHOOSES THE REFUSAL BEFORE THE LIST IS BUILT. The connection's // sentence used to be said for BOTH doors, which answered a request about a // file with an answer about folders and left the person who did not know the diff --git a/internal/tui3/followup.go b/internal/tui3/followup.go index 63bd54cd44..8596abba6b 100644 --- a/internal/tui3/followup.go +++ b/internal/tui3/followup.go @@ -77,6 +77,7 @@ func (a *app) followUp() tea.Cmd { if line == "" { return nil } + a.noticeEvent(eventQueued) agent := a.agent // The model reads the paste and the queue's row keeps the tag (pastechip.go). spoken, line := a.composed(line) diff --git a/internal/tui3/homedraft.go b/internal/tui3/homedraft.go index 890e6e68cf..ff8c9794a2 100644 --- a/internal/tui3/homedraft.go +++ b/internal/tui3/homedraft.go @@ -350,6 +350,7 @@ func (a *app) moveTarget() bool { // It opens on the target's own model for [picker.start]'s stated reason: the // cursor sits on what you are on, so enter confirms rather than changes. func (a *app) openTargetPicker() { + a.noticeEvent(eventModelListOpened) a.target.pick.startFor(a.modelsFor(chatModel), a.targetModel(), chatModel) // AND THE PROVIDERS OPEN HERE TOO. The box under this list has always named // `→ providers`, and for one wave the key did nothing at all, because the diff --git a/internal/tui3/homeexchange.go b/internal/tui3/homeexchange.go index bf0746f70b..e318895aac 100644 --- a/internal/tui3/homeexchange.go +++ b/internal/tui3/homeexchange.go @@ -962,6 +962,8 @@ func (a *app) askHereWith(text string, orders ErrandOrders) tea.Cmd { if text == "" { return nil } + // The door was found, whatever it answers below (notice.go). + a.noticeEvent(eventAsked) if a.updateStopsTurn() { return nil } diff --git a/internal/tui3/hometip_test.go b/internal/tui3/hometip_test.go new file mode 100644 index 0000000000..5468d25b7c --- /dev/null +++ b/internal/tui3/hometip_test.go @@ -0,0 +1,278 @@ +package tui3 + +import ( + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/manual" +) + +// ── HOME'S ROW: THE SAME TIPS, THE OTHER BOX ───────────────────────────────── +// +// The conversation's foot has carried earned hints since notice.go was written; +// home, the other box a person types into, said nothing. These pin the row +// above home's rule: it says a tip over an idle box, says nothing while the box +// is being typed into, moves on every visit and every [homeHintEvery] at rest, +// and a tip spent on either box is spent on both. + +// The frame's row directly above the rule is the tip, and only over an empty +// box with nothing else up. +func TestHomeRowSaysATipOverAnEmptyBox(t *testing.T) { + lab := newHomeLab(t) + a := lab.door("") + a.key(key("esc")) + if !a.at(pageHome) { + t.Fatal("esc did not open home") + } + tip := a.noticeHomeHint() + if tip == "" { + t.Fatalf("home opened with nothing on its row; the slot holds %q", a.notices.current[slotHome]) + } + if !strings.Contains(homeText(a), tip) { + t.Fatalf("the tip is not on the frame:\n%s", homeText(a)) + } + if !strings.HasPrefix(tip, "/") && !strings.HasPrefix(tip, "ctrl+") && !strings.HasPrefix(tip, "alt+") && + !strings.HasPrefix(tip, "opt+") && !strings.HasPrefix(tip, "@") && !strings.HasPrefix(tip, "ask ") { + t.Fatalf("the tip does not open with the key or the command: %q", tip) + } + + // A BOX WITH WORDS IN IT IS THE SENTENCE'S. The row goes blank and comes + // back when the box is empty again. + a.key(key("x")) + if got := a.noticeHomeHint(); got != "" { + t.Fatalf("a tip drew over a box with words in it: %q", got) + } + if strings.Contains(homeText(a), tip) { + t.Fatalf("the tip is still on the frame over a typed box:\n%s", homeText(a)) + } + a.key(key("backspace")) + if got := a.noticeHomeHint(); got != tip { + t.Fatalf("the row reads %q after the box emptied, want %q", got, tip) + } + + // AND THE COMMAND LIST OUTRANKS IT, the way every list does. + a.key(key("/")) + if got := a.noticeHomeHint(); got != "" { + t.Fatalf("a tip drew under the command list: %q", got) + } + a.key(key("backspace")) + + // OFF IS OFF. The Display tab's row silences this slot with the other. + a.notices.enabled = false + if got := a.noticeHomeHint(); got != "" { + t.Fatalf("a silenced profile still says %q on home", got) + } + if strings.Contains(homeText(a), tip) { + t.Fatal("a silenced tip is still drawn") + } +} + +// Every road home moves the row on; so does the beat once a tip has stood +// [homeHintEvery] at rest — and neither moves it while the box is being typed +// into, because a tip nobody could read has not been shown. +func TestHomeRowMovesOnEveryVisitAndAtRest(t *testing.T) { + lab := newHomeLab(t) + a := lab.door("") + now := time.Date(2026, 9, 21, 10, 0, 0, 0, time.UTC) + a.clock = func() time.Time { return now } + + a.showPage(pageHome) + first := a.notices.current[slotHome] + if first == "" { + t.Fatal("the first visit put nothing on the row") + } + a.showPage(pageHome) + second := a.notices.current[slotHome] + if second == first || second == "" { + t.Fatalf("a second visit left %q standing", second) + } + + // AT REST THE BEAT MOVES IT, but not before its time. + now = now.Add(homeHintEvery - time.Second) + a.noticeHomeBeat() + if got := a.notices.current[slotHome]; got != second { + t.Fatalf("the beat moved the row early, to %q", got) + } + now = now.Add(2 * time.Second) + a.noticeHomeBeat() + third := a.notices.current[slotHome] + if third == second || third == "" { + t.Fatalf("the beat left %q standing past its time", third) + } + + // A BOX BEING TYPED INTO DOES NOT AGE THE ROW. + a.key(key("x")) + now = now.Add(2 * homeHintEvery) + a.noticeHomeBeat() + if got := a.notices.current[slotHome]; got != third { + t.Fatalf("the beat moved the row under a typed box, to %q", got) + } + a.key(key("backspace")) + + // AND THE RING COMES ROUND: every eligible tip has its turn before any + // repeats, in the table's order. + seen := map[string]bool{first: true, second: true, third: true} + eligible := 0 + for _, n := range notices { + if n.draws(slotHome) && n.armed(a) { + eligible++ + } + } + for i := 3; i < eligible; i++ { + a.showPage(pageHome) + id := a.notices.current[slotHome] + if seen[id] { + t.Fatalf("visit %d repeated %q before the ring came round (%d eligible)", i+1, id, eligible) + } + seen[id] = true + } + a.showPage(pageHome) + if got := a.notices.current[slotHome]; got != first { + t.Fatalf("after the whole ring the row holds %q, want %q again", got, first) + } +} + +// A tip retired from home is retired from the conversation's foot as well, and +// the row moves on at once rather than standing empty. +func TestATipSpentOnHomeIsSpentEverywhere(t *testing.T) { + lab := newHomeLab(t) + a := lab.door("") + a.showPage(pageHome) + id := a.notices.current[slotHome] + var row notice + for _, n := range notices { + if n.id == id { + row = n + } + } + if row.id == "" || row.retire == "" { + t.Fatalf("home holds %q, which has no gesture to retire it", id) + } + a.noticeEvent(row.retire) + if !a.notices.retired(id) { + t.Fatalf("%q was not retired by %q", id, row.retire) + } + if got := a.notices.current[slotHome]; got == id || got == "" { + t.Fatalf("home's row holds %q after the gesture", got) + } + if got := a.notices.current[slotHint]; got == id { + t.Fatalf("the conversation's slot still holds %q after the gesture", id) + } + a.showPage(pageHome) + a.showPage(pageHome) + a.showPage(pageHome) + for i := 0; i < len(notices); i++ { + if a.notices.current[slotHome] == id { + t.Fatal("a retired tip came back round on home") + } + a.showPage(pageHome) + } +} + +// Home counts every turn of its rotation as a showing, and a tip that has come +// round [homeShownDefault] times is taken as read. +func TestHomeCountsEveryTurnOfItsRotation(t *testing.T) { + b := bareNoticeBoard() + cands := []noticeCandidate{{id: "a", armed: true}, {id: "b", armed: true}} + turns := map[string]int{} + for i := 0; i < 2*homeShownDefault; i++ { + b.homeAdvance = true + id := b.pick(slotHome, cands, 0) + if id == "" { + t.Fatalf("turn %d put nothing on the row", i) + } + b.take(slotHome, id, homeShownDefault, 0) + turns[id]++ + } + if turns["a"] != homeShownDefault || turns["b"] != homeShownDefault { + t.Fatalf("the ring did not share the turns evenly: %v", turns) + } + if !b.retired("a") || !b.retired("b") { + t.Fatalf("after %d turns each the tips are not retired: %+v", homeShownDefault, b.ledger) + } + b.homeAdvance = true + if got := b.pick(slotHome, cands, 0); got != "" { + t.Fatalf("a retired tip came back: %q", got) + } +} + +// A tip that stops being eligible stands down at once and the next takes over, +// without waiting for a visit — and an event between visits otherwise leaves +// the row alone. +func TestHomeRowHoldsBetweenVisitsAndYieldsWhenSpent(t *testing.T) { + b := bareNoticeBoard() + cands := []noticeCandidate{{id: "a", armed: true}, {id: "b", armed: true}, {id: "c", armed: true}} + b.homeAdvance = true + if got := b.pick(slotHome, cands, 0); got != "a" { + t.Fatalf("the ring did not start at the top: %q", got) + } + b.take(slotHome, "a", homeShownDefault, 0) + // An event with nothing advancing keeps the one standing. + if got := b.pick(slotHome, cands, 0); got != "a" { + t.Fatalf("an event moved the row without a visit, to %q", got) + } + // The one standing retiring hands the row to the next in the ring. + b.retire("a") + if got := b.pick(slotHome, cands, 0); got != "b" { + t.Fatalf("a spent tip did not yield to the next: %q", got) + } + // And with nothing eligible the row is empty rather than stale. + for _, c := range cands { + b.retire(c.id) + } + if got := b.pick(slotHome, cands, 0); got != "" { + t.Fatalf("an empty ring still says %q", got) + } +} + +// THE MANUAL LAW, said for the tips: every line the table can draw is on the +// hints page word for word, so a person who asks the chat what a tip meant is +// answered from the page rather than improvised at. +func TestEveryTipIsOnTheManualPage(t *testing.T) { + for _, n := range notices { + if n.slot != slotHint || n.text == "" { + continue + } + if !manual.Chat().Mentions(n.text) { + t.Errorf("the hints page does not carry the tip %q (notice %q)", n.text, n.id) + } + } +} + +// The cut was thirty, and the row above home's rule draws only rows that name +// it: a note row that names a box, or a row filed under home's slot, does not +// build. +func TestTheTableIsThirtyHintsAndEachNamesItsBoxes(t *testing.T) { + hints, home, chat := 0, 0, 0 + for _, n := range notices { + if n.slot != slotHint { + continue + } + hints++ + if n.draws(slotHome) { + home++ + } + if n.draws(slotHint) { + chat++ + } + } + if hints != 30 { + t.Fatalf("the table holds %d hints, want 30 — the cut is deliberate, and the manual page counts them", hints) + } + if home == 0 || chat == 0 { + t.Fatalf("%d hints draw on home and %d in a conversation; both boxes need some", home, chat) + } + // A hint that names no box is the conversation's, which is what every row + // meant before home had a row. + plain := notice{id: "boxless", slot: slotHint, armed: ready, text: "x"} + if !plain.draws(slotHint) || plain.draws(slotHome) { + t.Fatal("a hint naming no box is not the conversation's alone") + } + if err := checkNotices([]notice{{id: "noted", slot: slotNote, place: onHome, armed: ready, text: "x"}}); err == nil { + t.Fatal("a note naming a box was accepted") + } + if err := checkNotices([]notice{{id: "filed", slot: slotHome, armed: ready, text: "x"}}); err == nil { + t.Fatal("a row filed under home's slot was accepted") + } +} diff --git a/internal/tui3/memory.go b/internal/tui3/memory.go index 96e8d94607..f72d3d1a7b 100644 --- a/internal/tui3/memory.go +++ b/internal/tui3/memory.go @@ -57,6 +57,7 @@ const memoryOffNote = "memory is off for this session · turn it on under /setti // runRemember is /remember: keep one thing across conversations. func (a *app) runRemember(text string) { + a.noticeEvent(eventRemembered) if a.hosted() { a.note(memoryRemoteWord) return diff --git a/internal/tui3/notice.go b/internal/tui3/notice.go index fbe83d3490..058ab3f2bc 100644 --- a/internal/tui3/notice.go +++ b/internal/tui3/notice.go @@ -7,6 +7,7 @@ import ( "fmt" "regexp" "strings" + "time" "github.com/Agent-Field/codeaf/internal/buildinfo" ) @@ -64,10 +65,32 @@ const ( // for news: a hint belongs beside the box it is about, and a line in the // conversation is for something that is true once. slotNote + // slotHome is the dim row directly above the rule over home's box + // (pages.go's [placeFrameWithBar]), and it is the hint slot's twin on the + // other box a person types into: the same table, the same ledger, the same + // retirement — and a different clock, because home has no turns. The rows + // that may stand in it are the hint rows whose [notice.place] says so, so a + // tip retired by its gesture is retired on both boxes at once. + slotHome // noticeSlots is how many there are. A new slot goes above this line. noticeSlots ) +// hintPlace is WHERE a hint row may draw: the conversation's foot, home's row, +// or both. It is a set rather than a second slot on the row because one tip is +// one promise — `/model lists every model` is as true on home as it is in a +// conversation, and a person who opened the picker from either has learned it. +type hintPlace uint8 + +const ( + // inChat is the conversation's foot ([slotHint]). + inChat hintPlace = 1 << iota + // onHome is the row above home's rule ([slotHome]). + onHome + // everywhere is both. + everywhere = inChat | onHome +) + // The events that prove a gesture happened. They are named constants beside the // table so a retire rule cannot be spelled with a typo and silently never // fire: [checkNotices] refuses a rule naming an event that is not in @@ -112,6 +135,54 @@ const ( // eventDeliverableMade is something written for the person: an export that // landed on disk. eventDeliverableMade = "deliverable-made" + // eventAsked is a question sent through home's own door — `/ask`, or + // `alt+enter` over home's box (homeexchange.go's [app.askHereWith]). + eventAsked = "asked" + // eventTaskTyped is `/task ` reaching its command (taskcommand.go); + // the task it starts fires [eventTaskStarted] on its own later. + eventTaskTyped = "task-typed" + // eventSpelledOut is `ctrl+r` asking for the draft to be spelled out + // (spellout.go's [app.spellAsk]). + eventSpelledOut = "spelled-out" + // eventAtOpened is the `@` completion list coming up under the box + // (app.go's [app.syncLists]). + eventAtOpened = "at-opened" + // eventAttached is a file or picture put on the tray by path, or the + // browser opened to choose one (attach.go, folderplace.go). + eventAttached = "attached" + // eventFolderPicked is the folder chooser raised, from a conversation or + // aimed at home's target (folderplace.go). + eventFolderPicked = "folder-picked" + // eventModelListOpened is the model list raised, over a conversation or + // over home's draft (palette.go, homedraft.go). + eventModelListOpened = "model-list-opened" + // eventCrewShown is /crew answered, bare or with a preset (crew.go). + eventCrewShown = "crew-shown" + // eventBudgetShown is /budget answered, bare or with a figure (budget.go). + eventBudgetShown = "budget-shown" + // eventSpendOpened is the spend place raised by any door (pages.go). + eventSpendOpened = "spend-opened" + // eventSteered is enter over a running answer steering it (steer.go). + eventSteered = "steered" + // eventQueued is ctrl+q holding a message for after the turn (followup.go). + eventQueued = "queued" + // eventChatStarted is the new-chat page raised by ctrl+t or the tab strip's + // plus (chatstart.go). + eventChatStarted = "chat-started" + // eventPlaceJumped is alt+ reaching a place (placekeys.go). + eventPlaceJumped = "place-jumped" + // eventRemembered is /remember reaching its command (memory.go). + eventRemembered = "remembered" + // eventSearchOpened is the search place raised by any door (pages.go). + eventSearchOpened = "search-opened" + // eventSubharnessOpened is /subharness reaching its command, bare or named + // (app.go). + eventSubharnessOpened = "subharness-opened" + // eventConnectOpened is the connect panel reached for (connectpanel.go). + eventConnectOpened = "connect-opened" + // eventMediaAsked is the session beginning a picture, sound, music or video + // call — proof the person knows to ask (app.go's event seam). + eventMediaAsked = "media-asked" ) // noticeEvents is every event there is, in one list, so the table check can @@ -121,6 +192,18 @@ var noticeEvents = []string{ eventMenuOpened, eventRewound, eventCopyEntered, eventModelSwitched, eventCompacted, eventFilesOpened, eventResumeOpened, eventCostShown, eventStandingOpened, eventDeliverableMade, + eventAsked, eventTaskTyped, eventSpelledOut, eventAtOpened, eventAttached, + eventFolderPicked, eventModelListOpened, eventCrewShown, eventBudgetShown, + eventSpendOpened, eventSteered, eventQueued, eventChatStarted, + eventPlaceJumped, eventRemembered, eventSearchOpened, eventSubharnessOpened, + eventConnectOpened, eventMediaAsked, +} + +// mediaTools is every tool whose call proves a person asked for a picture, a +// voice, music or film; the belt's own names (internal/session). +var mediaTools = map[string]bool{ + "generate_image": true, "generate_video": true, "generate_music": true, + "speak": true, "edit_video": true, } // notice is one thing the surface may tell a person, and the whole of the rule @@ -131,8 +214,15 @@ type notice struct { // a person has already been told this and does not want to be again. id string slot noticeSlot - // priority decides between two notices eligible for one slot at once; - // higher wins, and the table's order breaks a tie. + // place is where a [slotHint] row may draw — the conversation's foot, home's + // row, or both. The zero value is the conversation's foot, which is what + // every row meant before home had a row; a news row leaves it zero. Home's + // row takes the rows that name it in the table's order, round and round + // ([noticeBoard.pick]). + place hintPlace + // priority decides between two notices eligible for the conversation's + // slot at once; higher wins, and the table's order breaks a tie. Home's row + // ignores it: there, every eligible tip has its turn. priority int // armed says whether the notice is relevant right now. It is asked at every // event and never between them, so it must be cheap and must read only what @@ -148,10 +238,12 @@ type notice struct { // notice never shows again on this profile. Empty for a notice that only // ages out. retire string - // maxShown is how many SESSIONS the notice may be shown in before it retires - // by itself, whether or not the gesture was ever used; zero means - // [noticeShownDefault]. Counted per session and not per frame, because a - // hint standing in the slot for an hour is one showing. + // maxShown is how many showings the notice gets before it retires by + // itself, whether or not the gesture was ever used; zero means the default + // for where it draws — [noticeShownDefault] in a conversation, where a + // showing is a session, and [homeShownDefault] for a row home takes, where + // a showing is one turn of home's rotation. A hint standing in a slot for + // an hour is one showing either way. maxShown int // news marks the what's-new channel: a row that is armed only on the first // launch after the binary's build changed, and shown once. @@ -164,6 +256,17 @@ type notice struct { // does not want, and the fourth showing would be the surface nagging. const noticeShownDefault = 3 +// homeShownDefault is how many turns of home's rotation a tip may take before +// it is taken as read. It is twice the conversation's figure because home's +// showings are shorter and more frequent: the row changes on every visit and +// every couple of minutes at rest, so six showings is still one afternoon. +const homeShownDefault = 6 + +// homeHintEvery is how long a tip stands on home's row before the next one +// takes it, while home is left at rest. Two minutes is long enough to be read +// and short enough that a home left open over lunch has said a few things. +const homeHintEvery = 2 * time.Minute + // noticeGap is the fewest turns between one hint standing down and a different // one taking the slot. It is what keeps a busy first session from reading as a // slideshow: three hints arming in three consecutive turns are shown one at a @@ -184,12 +287,41 @@ const ( costHintUSD = 0.10 ) +// The arming rules the table shares. A rule reads only what the surface +// already holds ([notice.armed] says why), and these are the three facts most +// rows want: nothing at all, a conversation that has been spoken to, and home's +// own door standing. +var ( + // ready is a tip that is true as soon as there is somebody to tell: on + // home from the first minute, and in a conversation once it has had an + // exchange. A fresh conversation's foot stays quiet until then, which is + // the law the `/ shows every command` row kept when it was here — the + // greeting is the first thing a person reads, not a tip. + ready = func(a *app) bool { return a.turn >= 1 || a.at(pageHome) } + // spoken is a conversation that has had at least one exchange: a tip about + // steering or queueing over an answer means nothing before one has arrived. + spoken = func(a *app) bool { return a.turn >= 1 } + // askable is home's own door standing — the errand builder a launch may or + // may not hand the surface (homeexchange.go's [app.askHereWith]). + askable = func(a *app) bool { return a.errand != nil } +) + // notices is the table, in priority order for reading. Text is chosen to agree // with the manual page that answers each hint (internal/manual/chat's -// hints-and-tips.md), so the tip and the page say the same words. +// hints-and-tips.md), so the tip and the page say the same words — and +// notice_test.go holds the page to every line here, so the table cannot say a +// thing the manual does not. +// +// THIRTY ROWS, AND THE CUT WAS DELIBERATE. A survey of the surface on +// 2026-09-21 turned up forty-eight lines worth saying; these are the thirty +// that teach a door a person cannot see from the box. What was left out is +// what the foot already names — `alt+p`, `alt+e`, `alt+a`, `alt+k`, `/` — and +// the second spelling of anything already here. `/ shows every command` was a +// row until both feet started saying `/ commands` outright (footswap.go). var notices = []notice{ + // ── the seven that were here first ────────────────────────────────────── { - id: "compact-at-half", slot: slotHint, priority: 90, + id: "compact-at-half", slot: slotHint, place: everywhere, priority: 90, armed: func(a *app) bool { pct, ok := a.ctxPercent() return ok && pct >= contextHintPct @@ -198,50 +330,188 @@ var notices = []notice{ retire: eventCompacted, }, { - id: "cost-after-spend", slot: slotHint, priority: 85, + id: "cost-after-spend", slot: slotHint, place: everywhere, priority: 85, armed: func(a *app) bool { return a.cost >= costHintUSD }, text: "/cost says what this conversation has spent", retire: eventCostShown, }, { - id: "task-page-after-first-task", slot: slotHint, priority: 80, + id: "task-page-after-first-task", slot: slotHint, place: everywhere, priority: 80, armed: func(a *app) bool { return a.notices.seen[eventTaskStarted] }, text: "ctrl+. sees every task this project has run", retire: eventTaskPageOpened, }, { - id: "rewind-after-long-answer", slot: slotHint, priority: 70, + id: "rewind-after-long-answer", slot: slotHint, place: everywhere, priority: 70, armed: func(a *app) bool { return a.lastAnswerRunes() >= longAnswerRunes }, text: "/rewind takes back an earlier message", retire: eventRewound, }, { - id: "files-after-first-deliverable", slot: slotHint, priority: 60, + id: "files-after-first-deliverable", slot: slotHint, place: everywhere, priority: 60, armed: func(a *app) bool { return a.notices.seen[eventDeliverableMade] }, text: "/files finds everything made for you", retire: eventFilesOpened, }, - { - id: "menu-after-first-turn", slot: slotHint, priority: 50, - armed: func(a *app) bool { return a.turn >= 1 }, - text: "/ shows every command", - retire: eventMenuOpened, - }, { // The welcome box already walked this directory for its recent column // (welcome.go), so the fact is at hand for nothing; a fresh directory // with no earlier conversation has an empty list and the hint stays down. - id: "resume-when-earlier-exists", slot: slotHint, priority: 40, + id: "resume-when-earlier-exists", slot: slotHint, place: everywhere, priority: 40, armed: func(a *app) bool { return len(a.welcome.recent) > 0 }, text: "/resume opens an earlier conversation", retire: eventResumeOpened, }, { - id: "standing-after-several-sessions", slot: slotHint, priority: 10, + id: "standing-after-several-sessions", slot: slotHint, place: everywhere, priority: 10, armed: func(a *app) bool { return len(a.welcome.recent) >= 3 }, text: "/standing keeps something always true", retire: eventStandingOpened, }, + // ── starting work ─────────────────────────────────────────────────────── + { + id: "ask-on-home", slot: slotHint, place: onHome, + armed: askable, + text: "/ask answers right here without opening a conversation", + retire: eventAsked, + }, + { + id: "task-from-home", slot: slotHint, place: onHome, + armed: askable, + text: "alt+enter sends what you typed off as a task", + retire: eventAsked, + }, + { + id: "task-in-chat", slot: slotHint, place: inChat, priority: 55, + armed: spoken, + text: "/task starts work you can walk away from", + retire: eventTaskTyped, + }, + { + id: "standing-by-chord", slot: slotHint, place: everywhere, priority: 20, + armed: ready, + text: "ctrl+enter sends your message as something to keep true", + retire: eventStandingOpened, + }, + { + id: "spell-out", slot: slotHint, place: everywhere, priority: 26, + armed: func(a *app) bool { _, ok := a.spellDoor(); return ok }, + text: "ctrl+r spells out what your sentence is taken to mean", + retire: eventSpelledOut, + }, + // ── files and context ─────────────────────────────────────────────────── + { + id: "at-completion", slot: slotHint, place: everywhere, priority: 28, + armed: ready, + text: "@ completes a file, a folder or a task into your message", + retire: eventAtOpened, + }, + { + id: "attach-a-file", slot: slotHint, place: everywhere, priority: 24, + armed: ready, + text: "/attach sends a file along with your message", + retire: eventAttached, + }, + { + id: "pick-a-folder", slot: slotHint, place: everywhere, priority: 22, + armed: ready, + text: "/folder picks the folder codeaf works in", + retire: eventFolderPicked, + }, + { + id: "attach-a-picture", slot: slotHint, place: everywhere, priority: 6, + armed: ready, + text: "/image attaches a picture, or paste a screenshot in", + retire: eventAttached, + }, + { + id: "export-the-conversation", slot: slotHint, place: inChat, priority: 30, + armed: func(a *app) bool { return a.turn >= 2 }, + text: "/export writes this whole conversation to a file", + retire: eventDeliverableMade, + }, + // ── models, thinking and cost ─────────────────────────────────────────── + { + id: "model-list", slot: slotHint, place: everywhere, priority: 25, + armed: ready, + text: "/model lists every model, /model switches at once", + retire: eventModelListOpened, + }, + { + id: "crew-presets", slot: slotHint, place: everywhere, priority: 13, + armed: ready, + text: "/crew sets the models codeaf uses on its own behalf", + retire: eventCrewShown, + }, + { + id: "budget-cap", slot: slotHint, place: everywhere, priority: 14, + armed: ready, + text: "/budget caps what today may cost", + retire: eventBudgetShown, + }, + { + id: "spend-place", slot: slotHint, place: everywhere, priority: 15, + armed: ready, + text: "alt+3 shows what this machine has spent, by the day", + retire: eventSpendOpened, + }, + // ── steering a running answer ─────────────────────────────────────────── + { + id: "steer-with-enter", slot: slotHint, place: inChat, priority: 45, + armed: spoken, + text: "enter while an answer is coming stops it and steers", + retire: eventSteered, + }, + { + id: "queue-with-ctrl-q", slot: slotHint, place: inChat, priority: 35, + armed: spoken, + text: "ctrl+q queues this message for after the current turn", + retire: eventQueued, + }, + // ── moving around ─────────────────────────────────────────────────────── + { + id: "new-chat", slot: slotHint, place: everywhere, priority: 18, + armed: ready, + text: "ctrl+t starts a fresh chat in this folder", + retire: eventChatStarted, + }, + { + id: "place-chords", slot: slotHint, place: everywhere, priority: 16, + armed: ready, + text: "alt+1 to alt+7 jump straight to a place", + retire: eventPlaceJumped, + }, + // ── memory, accounts and the rest ─────────────────────────────────────── + { + id: "remember-one-thing", slot: slotHint, place: everywhere, priority: 12, + armed: ready, + text: "/remember keeps one thing across conversations", + retire: eventRemembered, + }, + { + id: "search-place", slot: slotHint, place: everywhere, priority: 11, + armed: ready, + text: "/search finds anything ever said on this machine", + retire: eventSearchOpened, + }, + { + id: "subharness-list", slot: slotHint, place: everywhere, priority: 9, + armed: ready, + text: "/subharness lists the programs you can run", + retire: eventSubharnessOpened, + }, + { + id: "connect-accounts", slot: slotHint, place: everywhere, priority: 8, + armed: ready, + text: "/connect links Google, Slack or another model service", + retire: eventConnectOpened, + }, + { + id: "ask-for-media", slot: slotHint, place: everywhere, priority: 7, + armed: ready, + text: "ask for a picture, a voiceover, music or a video", + retire: eventMediaAsked, + }, } // noticeBanned is the machinery vocabulary no person-facing line may carry. @@ -276,6 +546,10 @@ func checkNotices(list []notice) error { return fmt.Errorf("notice %q retires on %q, which nothing fires", n.id, n.retire) case n.maxShown < 0: return fmt.Errorf("notice %q has a negative showing limit", n.id) + case n.slot != slotHint && n.place != 0: + return fmt.Errorf("notice %q names a box to draw beside but is not a hint", n.id) + case n.slot == slotHome: + return fmt.Errorf("notice %q is filed under home's slot; a hint names home through its place instead", n.id) } seen[n.id] = true for _, word := range noticeBanned { @@ -296,14 +570,29 @@ func init() { } } -// limit is the showing limit with the default applied. +// limit is the showing limit with the default applied: the row's own figure, +// else home's default for a row home takes, else the conversation's. func (n notice) limit() int { if n.maxShown > 0 { return n.maxShown } + if n.place&onHome != 0 { + return homeShownDefault + } return noticeShownDefault } +// draws reports whether the row may stand in a slot. +func (n notice) draws(slot noticeSlot) bool { + switch slot { + case slotHint: + return n.slot == slotHint && (n.place == 0 || n.place&inChat != 0) + case slotHome: + return n.slot == slotHint && n.place&onHome != 0 + } + return n.slot == slot +} + // line is what the notice says right now. func (n notice) line(a *app) string { if n.say != nil { @@ -342,6 +631,15 @@ type noticeBoard struct { // lastHintTurn is the turn the hint slot last changed hands on, or -1 when // it never has; [noticeGap] is measured from it. lastHintTurn int + // homeAdvance asks the next decision about home's row to move on to the + // next eligible tip rather than keep the one standing. It is raised by + // [app.noticeHomeRotate] — a visit, or the beat at rest — and spent by the + // pick that honours it, so an event between two rotations leaves the row + // alone unless the tip on it has just retired. + homeAdvance bool + // homeAt is when home's row last changed hands, or zero when it never + // has; [homeHintEvery] is measured from it by the beat. + homeAt time.Time } // bareNoticeBoard is a board with nothing behind it: no ledger on disk, no @@ -431,6 +729,9 @@ type noticeCandidate struct { // arming fact stopped being true stands down at once. func (b *noticeBoard) pick(slot noticeSlot, cands []noticeCandidate, turn int) string { held := b.current[slot] + if slot == slotHome { + return b.pickHome(cands, held) + } best, found := noticeCandidate{}, false for _, c := range cands { if !c.armed || b.done[c.id] || b.retired(c.id) { @@ -456,10 +757,43 @@ func (b *noticeBoard) pick(slot noticeSlot, cands []noticeCandidate, turn int) s return best.id } +// pickHome is [noticeBoard.pick] for home's row, and it is a rotation rather +// than a ranking: EVERY ELIGIBLE TIP HAS ITS TURN, in the table's order, round +// and round. The one standing keeps standing until [homeAdvance] asks for the +// next — or until it stops being eligible, when the next takes over at once +// so the row is never blank while there is something true to say. With one +// eligible tip the rotation is that tip; with none the row is empty. +func (b *noticeBoard) pickHome(cands []noticeCandidate, held string) string { + eligible := func(c noticeCandidate) bool { return c.armed && !b.done[c.id] && b.retired(c.id) == false } + at := -1 + for i, c := range cands { + if c.id == held { + at = i + } + } + advance := b.homeAdvance + b.homeAdvance = false + if at >= 0 && !advance && eligible(cands[at]) { + return held + } + // Walk the ring from the one after the held one, back round to it. + for step := 1; step <= len(cands); step++ { + c := cands[(at+step+len(cands))%len(cands)] + if eligible(c) { + return c.id + } + } + return "" +} + // take records that a slot now holds id — counting the showing once per // session, retiring the notice when this showing was its last allowed, and // noting the turn so the gap can be measured. It reports whether the slot's // occupant changed, and whether the ledger did. +// +// HOME COUNTS EVERY TURN OF ITS ROTATION AS A SHOWING, where the conversation's +// slot counts a session: a tip that has come round six times on home has been +// read six times, however many launches that took ([homeShownDefault]). func (b *noticeBoard) take(slot noticeSlot, id string, limit int, turn int) (changed, wrote bool) { if b.current[slot] == id { return false, false @@ -471,7 +805,7 @@ func (b *noticeBoard) take(slot noticeSlot, id string, limit int, turn int) (cha if slot == slotHint { b.lastHintTurn = turn } - if b.shown[id] { + if slot != slotHome && b.shown[id] { return true, false } b.shown[id] = true @@ -532,7 +866,7 @@ func (a *app) noticeFill(slot noticeSlot) bool { cands := make([]noticeCandidate, 0, len(notices)) limits := make(map[string]int, len(notices)) for _, n := range notices { - if n.slot != slot || b.done[n.id] || b.retired(n.id) { + if !n.draws(slot) || b.done[n.id] || b.retired(n.id) { continue } if n.news && !b.news { @@ -543,12 +877,70 @@ func (a *app) noticeFill(slot noticeSlot) bool { } id := b.pick(slot, cands, a.turn) changed, wrote := b.take(slot, id, limits[id], a.turn) + if changed && slot == slotHome { + b.homeAt = a.now() + } if changed && id != "" { a.noticeShow(slot, id) } return wrote } +// noticeHomeRotate moves home's row on to the next tip: on every visit to home +// ([app.showPage]) and on the beat once a tip has stood [homeHintEvery] at +// rest ([app.noticeHomeBeat]). Rotating is the one thing an event does not do +// to this slot, so it is its own seam. +func (a *app) noticeHomeRotate() { + b := &a.notices + if b.seen == nil { + *b = bareNoticeBoard() + } + b.homeAdvance = true + if a.noticeFill(slotHome) { + b.save() + } + a.touch() +} + +// noticeHomeBeat is home's clock asking whether the row is due to move +// (app.go's [homeTickMsg]): it is, once the tip standing has been up for +// [homeHintEvery] while home was quiet enough for it to be read. A row nobody +// could see — the box being typed into, a list up — does not age, because +// what has not been read has not been shown. +func (a *app) noticeHomeBeat() { + b := &a.notices + if b.current[slotHome] == "" || !a.noticeHomeQuiet() { + return + } + if a.now().Sub(b.homeAt) >= homeHintEvery { + a.noticeHomeRotate() + } +} + +// noticeHomeHint is the line standing on home's row, while home is quiet +// enough for it to be read over an idle box, spelled for this terminal's +// keyboard (chords.go's [chordSpelling.say] turns `alt` into `opt` on a Mac). +func (a *app) noticeHomeHint() string { + b := &a.notices + id := b.current[slotHome] + if id == "" || !b.enabled || !a.noticeHomeQuiet() { + return "" + } + for _, n := range notices { + if n.id == id { + return a.chords.say(n.line(a)) + } + } + return "" +} + +// noticeHomeQuiet is whether nothing on home outranks a tip: the box is at +// rest, no list or layer has the keyboard, and no exchange is being read. +func (a *app) noticeHomeQuiet() bool { + return a.at(pageHome) && a.home.box.empty() && !a.home.cmd.open && !a.home.searching() && + a.paneExchange() == nil && !a.targetPickShowing() && !a.composer.open && !a.hopShowing() +} + // noticeShow puts a newly chosen notice where its slot draws. The hint slot is // read at render time ([app.noticeHint]) and needs nothing done here; the note // slot is a line in the transcript, said once, now. @@ -580,7 +972,7 @@ func (a *app) noticeHint() string { } for _, n := range notices { if n.id == id { - return n.line(a) + return a.chords.say(n.line(a)) } } return "" diff --git a/internal/tui3/notice_test.go b/internal/tui3/notice_test.go index 623392f98f..4a4bd5a2f9 100644 --- a/internal/tui3/notice_test.go +++ b/internal/tui3/notice_test.go @@ -130,7 +130,9 @@ func TestAHintAgesOutAcrossOrdinaryLaunches(t *testing.T) { t.Fatalf("an ordinary launch keeps its notices at %q, want %q", got, want) } - const hint = "menu-after-first-turn" + // The task tip is the one a first exchange arms highest (notice.go's + // table); `/ shows every command` stood here until both feet said it. + const hint = "task-in-chat" launch := func() *app { a := noticeApp(t, "") a.turn = 1 @@ -507,6 +509,46 @@ func TestEveryRetireEventIsProvedByItsGesture(t *testing.T) { eventCostShown: func(t *testing.T, a *app) { a.slash("/cost") }, eventStandingOpened: func(t *testing.T, a *app) { a.slash("/standing") }, eventDeliverableMade: func(t *testing.T, a *app) { a.exportDone(exportedMsg{path: "/tmp/lab/talk.md"}) }, + eventAsked: func(t *testing.T, a *app) { a.askHere("what is this") }, + eventTaskTyped: func(t *testing.T, a *app) { a.slash("/task") }, + eventSpelledOut: func(t *testing.T, a *app) { + a.input.setText("build me a login page") + a.spellAsk() + }, + eventAtOpened: func(t *testing.T, a *app) { + drive(t, a, key("@"), key("s"), key("h")) + if !a.comp.open { + t.Fatal("typing @ did not open the completion") + } + }, + eventAttached: func(t *testing.T, a *app) { a.slash("/attach") }, + eventFolderPicked: func(t *testing.T, a *app) { a.slash("/folder") }, + eventModelListOpened: func(t *testing.T, a *app) { a.slash("/model") }, + eventCrewShown: func(t *testing.T, a *app) { a.slash("/crew") }, + eventBudgetShown: func(t *testing.T, a *app) { a.slash("/budget") }, + eventSpendOpened: func(t *testing.T, a *app) { a.slash("/spend") }, + eventSteered: func(t *testing.T, a *app) { + a.state = stateWorking + a.input.setText("go left instead") + drive(t, a, key("enter")) + }, + eventQueued: func(t *testing.T, a *app) { + a.state = stateWorking + a.input.setText("and then this") + drive(t, a, key("ctrl+q")) + }, + eventChatStarted: func(t *testing.T, a *app) { drive(t, a, key("ctrl+t")) }, + eventPlaceJumped: func(t *testing.T, a *app) { drive(t, a, key("alt+3")) }, + eventRemembered: func(t *testing.T, a *app) { a.slash("/remember the parser is under internal") }, + eventSearchOpened: func(t *testing.T, a *app) { a.slash("/search") }, + eventSubharnessOpened: func(t *testing.T, a *app) { a.slash("/subharness") }, + eventConnectOpened: func(t *testing.T, a *app) { a.slash("/connect") }, + eventMediaAsked: func(t *testing.T, a *app) { + a.state = stateWorking + drive(t, a, streamEventMsg{gen: a.gen, ev: session.Event{ + Kind: session.EventToolBegin, CallID: "g1", Tool: "generate_image", Hint: "generate_image", + }}) + }, } for _, name := range noticeEvents { if name == eventBoot { diff --git a/internal/tui3/pages.go b/internal/tui3/pages.go index 6f3d5d8a3c..6875ace9da 100644 --- a/internal/tui3/pages.go +++ b/internal/tui3/pages.go @@ -1400,7 +1400,16 @@ func placeFrameWithBar(a *app, width, height int, for _, row := range rows { add(row.text, row.hit) } - add("", nil) + // THE BLANK OVER THE RULE IS HOME'S HINT ROW, when there is a tip to say + // and the box is at rest ([app.noticeHomeHint]). It is the same row either + // way — the foot is one height with a tip and without — and it is dim, + // one cell in, in the grammar every hint on this surface keeps: the key or + // the command, then what it does. + if tip := a.noticeHomeHint(); hasBox && tip != "" { + add(" "+pal.dim(fit(tip, width-2)), nil) + } else { + add("", nil) + } // AND HOME'S RULE IS A LEGEND RATHER THAN A LINE. The other six places have // nothing to put on it — you are IN them, and the tab bar four rows up says // which — but home's box is a draft for a conversation that does not exist @@ -2246,6 +2255,17 @@ func (a *app) showPage(id page) (cmd tea.Cmd) { return nil } a.page = id + // AND THE DOOR IS THE GESTURE THE TIPS ABOUT IT WAIT FOR (notice.go): a + // place reached by any road retires its tip, and every visit to home + // moves home's row on to the next. + switch id { + case pageHome: + a.noticeHomeRotate() + case pageSpend: + a.noticeEvent(eventSpendOpened) + case pageSearch: + a.noticeEvent(eventSearchOpened) + } return next.open(a) } diff --git a/internal/tui3/palette.go b/internal/tui3/palette.go index c87eaa0ef5..daf98913fa 100644 --- a/internal/tui3/palette.go +++ b/internal/tui3/palette.go @@ -2668,6 +2668,7 @@ func (p *picker) keysParts() (string, string, string) { // any other, and every slot says which models may answer it (settings.go's // [filterFor]). func (a *app) openPicker() { + a.noticeEvent(eventModelListOpened) a.pick.startFor(a.modelList(), a.model, chatModel) // THE PIN IS A SNAPSHOT, exactly as the model in use is: it is what marks a // row inside an open fold, and what the row in use says `via`, and neither diff --git a/internal/tui3/placekeys.go b/internal/tui3/placekeys.go index 91de722f4e..2f01b2566d 100644 --- a/internal/tui3/placekeys.go +++ b/internal/tui3/placekeys.go @@ -305,6 +305,7 @@ func (a *app) placeJumpKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { if !ok { return nil, false } + a.noticeEvent(eventPlaceJumped) return a.showPage(id), true } diff --git a/internal/tui3/spellout.go b/internal/tui3/spellout.go index e067862791..3df3950b2b 100644 --- a/internal/tui3/spellout.go +++ b/internal/tui3/spellout.go @@ -313,6 +313,9 @@ func (a *app) spellKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { // hint slot turns the build's spinner while it is out, and [app.wake] is what // keeps the frames coming for it — nothing else on the surface is moving. func (a *app) spellAsk() tea.Cmd { + // The chord was reached for; the tip that names it is only ever armed where + // the door below stands (notice.go). + a.noticeEvent(eventSpelledOut) door, ok := a.spellDoor() if !ok { return nil diff --git a/internal/tui3/steer.go b/internal/tui3/steer.go index e3d50568d1..1c67030f19 100644 --- a/internal/tui3/steer.go +++ b/internal/tui3/steer.go @@ -261,6 +261,7 @@ func (a *app) steerIn() tea.Cmd { // bottom of input.go's router would have done nothing with it anyway. return nil } + a.noticeEvent(eventSteered) waiting := len(a.parks) // The mark is deliberately not passed, for [app.bargeIn]'s reason: ctrl+enter // is the gesture that means "keep this true" and this one means "and also diff --git a/internal/tui3/taskcommand.go b/internal/tui3/taskcommand.go index 3998b4c57b..4e29b7e005 100644 --- a/internal/tui3/taskcommand.go +++ b/internal/tui3/taskcommand.go @@ -51,6 +51,8 @@ type taskStartedMsg struct { } func (a *app) runTaskCommand(arg string) tea.Cmd { + // The word was typed, bare or with a brief (notice.go). + a.noticeEvent(eventTaskTyped) arg = strings.TrimSpace(arg) if arg == "" { // A BARE /task IS THE ROSTER AND NOT A USAGE LINE. The margin's `+ /task` From 95b69bb69e85639635a579ca6682e61ae4d52e7b Mon Sep 17 00:00:00 2001 From: ZeroPoint95 Date: Tue, 22 Sep 2026 07:49:38 -0400 Subject: [PATCH 03/39] home: the tip sits right over the rule with a bulb and a cross, and the project moves to the keys row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner's placing (2026-09-22). Home's tip row is right-aligned to end one cell in from the edge, led by a bulb and closed by a cross a pointer can press: the cross puts the tip away until the next visit to home or the row's own two-minute turn, and writes nothing down, because a tip put away is not a tip learned. The bulb is an emoji by the owner's word, against the vocabulary's own no-emoji-in-chrome law, and hometip.go says so where it is spelled. The project left home's rule for the right end of the keys row under the box, right-justified. The keys keep their room: a long path is cut on the right, one ellipsis, and goes entirely where the keys leave it less than a word. It is still the door onto the folder chooser, and the pointer and the hover read its columns off the row it was drawn on. A conversation's seam still names its workspace at the right. The row that silences the tips is `disable hints` on the Workspace tab, off by default. The key underneath is still ui.hints meaning shown — a persisted identifier keeps its bytes — and internal/config inverts once, on the way in and out, so the panel and `codeaf config` say the same word. The manual's home, places, keys, asking-from-home, choosing-a-folder and commands pages stop drawing the project on the rule, and the hints page describes the bulb, the cross and the new row. Co-Authored-By: Claude Fable 5.1 --- internal/config/settings.go | 24 ++- internal/manual/chat/asking-from-home.md | 4 +- internal/manual/chat/choosing-a-folder.md | 4 +- internal/manual/chat/commands.md | 3 +- internal/manual/chat/hints-and-tips.md | 32 ++-- internal/manual/chat/home.md | 31 ++-- internal/manual/chat/keys.md | 7 +- internal/manual/chat/places.md | 16 +- internal/tui3/app.go | 8 + internal/tui3/home.go | 6 + internal/tui3/home_test.go | 5 + internal/tui3/homedraft.go | 18 +- internal/tui3/homefate_test.go | 7 +- internal/tui3/homeslash_test.go | 11 +- internal/tui3/hometip.go | 112 +++++++++++++ internal/tui3/hometiplayout_test.go | 195 ++++++++++++++++++++++ internal/tui3/notice.go | 7 +- internal/tui3/notice_test.go | 2 +- internal/tui3/pages.go | 34 +++- internal/tui3/placemouse.go | 9 +- internal/tui3/projectseam.go | 7 +- internal/tui3/settings.go | 12 +- 22 files changed, 480 insertions(+), 74 deletions(-) create mode 100644 internal/tui3/hometip.go create mode 100644 internal/tui3/hometiplayout_test.go diff --git a/internal/config/settings.go b/internal/config/settings.go index 727b9acda2..fdf3de2a02 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -2675,14 +2675,26 @@ func (s *Settings) build() []Setting { read: func() string { return formatBool(DraftPersistAt(dir)) }, write: func(raw string) error { return writeBool(dir, KeyDraftPersist, raw) }, }, + // THE ROW READS THE OTHER WAY UP FROM ITS KEY. `ui.hints` persists + // whether tips are SHOWN, and keeps doing so — a persisted identifier keeps + // its bytes — while the row a person reads is `disable hints`, off by + // default (the owner's word for it, 2026-09-22). The inversion lives here, + // once, so the chat's settings panel and `codeaf config` cannot disagree. Setting{ Key: KeyHints, Category: CategoryInterface, Kind: SettingBool, - Label: "hints", - Hint: "one-line tips above the message box, each shown until the key or command " + - "it names has been used once. Off silences them, and the what's-new line a " + - "new build may say with them. A change lands at the end of the next turn.", - read: func() string { return formatBool(HintsAt(dir)) }, - write: func(raw string) error { return writeBool(dir, KeyHints, raw) }, + Label: "disable hints", + Hint: "on silences the one-line tips — the keys row's in a conversation and the " + + "row above the rule on home — and the what's-new line a new build may say " + + "with them. Off, the default, shows each tip until the key or command it " + + "names has been used once. A change lands at the end of the next turn.", + read: func() string { return formatBool(!HintsAt(dir)) }, + write: func(raw string) error { + disabled, err := parseBool(raw) + if err != nil { + return err + } + return writeProfileValue(dir, KeyHints, !disabled) + }, }, Setting{ Key: KeyAttribution, Category: CategoryInterface, Kind: SettingBool, diff --git a/internal/manual/chat/asking-from-home.md b/internal/manual/chat/asking-from-home.md index 603774489c..65df85654c 100644 --- a/internal/manual/chat/asking-from-home.md +++ b/internal/manual/chat/asking-from-home.md @@ -7,9 +7,9 @@ from the command menu writes `/ask `, ready for your question, like `/task`. A b `/ask` waits for your words. An inline `/ask` tag works too. ``` - ─ glm-5.3-flash:auto · ◇ asks ─── project: ~/codeaf + ─ glm-5.3-flash:auto · ◇ asks ────────────────────────────────────────────── › /ask remind me at 6 to leave - alt+p project · alt+e effort · alt+a approvals · alt+k chats · / commands + alt+p project · alt+e effort · alt+a approvals · / commands project: ~/codeaf ``` Plain text followed by Enter starts a new conversation by default. Only search results diff --git a/internal/manual/chat/choosing-a-folder.md b/internal/manual/chat/choosing-a-folder.md index d449e43245..976a79b4ae 100644 --- a/internal/manual/chat/choosing-a-folder.md +++ b/internal/manual/chat/choosing-a-folder.md @@ -106,8 +106,8 @@ travel over ssh. ## /folder on the home screen — choosing the folder the next conversation opens in **On local home the same command opens the same sheet, aimed at a conversation that does not -exist yet.** Home's box is a draft for the conversation `enter` will open, and the rule above -it says where that will be: `glm-5.3-flash:auto · ◇ asks ─── project: ~/src/parser`. +exist yet.** Home's box is a draft for the conversation `enter` will open, and the right end +of the keys row under it says where that will be: `project: ~/src/parser`. `/folder`, `/place` and `/dir` typed there — bare, or with a path after them — open the browser to change that folder. Over `--host`, they do not open it: this machine's directory cannot be the far conversation's folder, so they say the refusal in the section above. diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index 7a862cdc76..e07895f4f3 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -1750,8 +1750,7 @@ after" are written the way you would say them — `20m`, `4h`, `1h30m` — and ` off. **Display** — how the surface draws itself and what it remembers of your typing. Rows: -"input history", "keep drafts", "task column", "hints" — the one-line tips above the -message box, and the what's-new lines with them (see *Hints and tips*) — "chat width", +"input history", "keep drafts", "task column", "chat width", "mouse", "timestamps", "turn work". There is no "nerd font" or "linear mode" row: icons need no patched font anywhere on this surface, and the accessible single-column rendering is the `--linear` flag at launch rather than a persisted setting. diff --git a/internal/manual/chat/hints-and-tips.md b/internal/manual/chat/hints-and-tips.md index edd5c9df52..1db3ae71d6 100644 --- a/internal/manual/chat/hints-and-tips.md +++ b/internal/manual/chat/hints-and-tips.md @@ -22,11 +22,14 @@ Home has a row of its own for the same tips, directly above the rule over its bo ## The dim sentence above the rule on home — the tip on home, what is that line over the box On home the tip is the dim row **directly above the rule** over the message box — the blank -that separates the list from the rule, with one sentence written into it. It reads the way -every tip does: the key or the command first, then what it does — `/ask answers right here -without opening a conversation`, `alt+1 to alt+7 jump straight to a place`. The keys row at -the very foot of home is not a tip and never changes: it names the row's options and the -draft's chords (`→ options · alt+p project · alt+e effort · alt+a approvals · / commands`). +that separates the list from the rule, with one sentence written into its right end, led +by a bulb: `💡 /ask answers right here without opening a conversation ✕`. It reads the way +every tip does: the key or the command first, then what it does. **The small cross after it +puts the tip away** — click it and the row is blank until your next visit to home or the +row's own two-minute turn brings the next tip; putting a tip away does not retire it. The +keys row at the very foot of home is not a tip and never changes: it names the row's options +and the draft's chords (`→ options · alt+p project · alt+e effort · alt+a approvals · / +commands`) and ends with `project: `, where the next conversation opens. It is there only while home is at rest: the box empty, no command list or model list up, no task or question open in the right pane. Type a letter and the row is blank again; clear @@ -151,15 +154,18 @@ home nothing wins: every tip that is true for you has its turn, in the order abo `/ shows every command` used to be one of these. It is gone because both keys rows now say `/ commands` outright, so there was nothing left to teach. -## Turn off hints — stop showing tips, disable the hints +## Turn off hints — stop showing tips, disable the hints, the disable hints row -Open the settings panel with `/settings` (or `ctrl+,`), go to the **Display** tab, and flip -the **hints** row off. Enter or space toggles it. The change lands at the end of the next -turn. Off silences the tips — in the conversation's keys row and on home's row alike — and -the what's-new lines together; it does not touch the keys the slot names for a live state — -`ctrl+c interrupt` and the rest are not hints and cannot be turned off. +Open the settings panel with `/settings` (or `ctrl+,`), go to the **Workspace** tab, and flip +the **disable hints** row on. Enter or space toggles it; it is off by default, which means +the tips show. (Until 2026-09-22 it was a **hints** row on the Display tab, on by default.) +The change lands at the end of the next turn. On silences the tips — in the conversation's +keys row and on home's row alike — and the what's-new lines together; it does not touch the +keys the slot names for a live state — `ctrl+c interrupt` and the rest are not hints and +cannot be turned off. From the terminal, `codeaf config` shows the same row under the same +name. -Turning the row back on shows whatever is due. Tips you had already retired stay retired. +Turning the row back off shows whatever is due. Tips you had already retired stay retired. ## What "news" lines are — what's new after an update @@ -171,4 +177,4 @@ of the same build says nothing. There is nothing to announce yet, so no news line has ever been printed by this build. A first launch on a fresh profile says nothing either — nothing is new to somebody who never saw -the older build. The **hints** row on the Display tab silences news lines along with the tips. +the older build. The **disable hints** row on the Workspace tab silences news lines along with the tips. diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index 50d3dc9860..ae2d0e986a 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -1176,15 +1176,18 @@ your words. Use `/ask ` to ask in a home pane instead. alpha ○ Pricing Sheet Import 2h ○ Pricing 12m ← one ↑ - ─ glm-5.3-flash:auto · ◇ asks ─── project: ~/codeaf + ─ glm-5.3-flash:auto · ◇ asks ─────────────────────────────────────── › pricing - alt+p project · alt+e effort · alt+a approvals · alt+k chats · / commands + alt+p project · alt+e effort · alt+a approvals · / commands project: ~/codeaf ``` -**Where it opens is on the rule above the box, and `enter` honours it.** That line reads -`glm-5.3-flash:auto · ◇ asks ─── project: ~/src/parser`: the model, effort and approvals -start at the left, with `project: ` at the far right naming where the conversation will open. -The effort word follows a colon with no badge. A long project path is cut on the right. +**Where it opens is at the right end of the keys row under the box, and `enter` honours it.** +The rule reads `glm-5.3-flash:auto · ◇ asks`: the model, effort and approvals start at the +left, and the keys row under the box ends with `project: `, right-justified, naming +where the conversation will open (until 2026-09-22 the path stood at the rule's right). +The effort word follows a colon with no badge. The keys keep their room: a long project +path is cut on the right, one ellipsis, and goes entirely where the keys leave it less +than a word. With nothing pinned the folder **follows the row your cursor is on** — walk onto another project's row and the rule re-points — and with nothing under the cursor it is this window's own project. `alt+p` pins it, `/model` pins the model, `alt+e` walks the rung and `alt+a` @@ -1294,10 +1297,10 @@ files are still there, and the conversation you open shows them. ## Change the model before starting — /model on home, the seam above the box **The model the next conversation will answer on is written on the rule above home's box**, -at the left: `z-ai/glm-5.3-flash:auto · ◇ asks ─── project: ~/src/parser`. +at the left: `z-ai/glm-5.3-flash:auto · ◇ asks`. Home and conversation seams both keep the complete model identifier, including the organization before `/`, for the current model or a model pinned for the next conversation. -The project sits at the right edge of the seam. A long path +The project sits at the right edge of the keys row under the box. A long path truncates at its right end before the project field disappears on narrow frames. With nothing pinned that is this window's own model. Two doors change it, and they are the same door: @@ -1332,10 +1335,11 @@ on the pin — at which point it is an ordinary model switch, note and all. conversation, returning home, moving the cursor or clearing the box does not reset the selected project. A new window starts with its own default. -**`alt+p` is the same gesture for the folder** — press it, or press the path on the rule, and +**`alt+p` is the same gesture for the folder** — press it, or press the path at the right of +the keys row, and the target walks through the projects in the panel's order, including projects with only standing work, and wraps after the last. Both controls share one selection, shown only -as `project: ` on the seam. If `/folder` selected a destination outside the panel, +as `project: ` at the right of the keys row. If `/folder` selected a destination outside the panel, the next cycle starts at its first project. With just one destination already selected, `alt+p project` is absent. @@ -1383,9 +1387,10 @@ key or a command you have not used yet, and what it does — `/ask answers right opening a conversation`, `alt+1 to alt+7 jump straight to a place`. It is drawn only while the box is empty and nothing else is up, it moves on to the next tip every time you come to home and every two minutes at rest, and each tip goes away for good the first time you do -what it names. The keys row at the very foot is not a tip and never changes. The whole list, -what makes each one appear and disappear, and the **hints** row on the Display tab that -turns them off, are on the *hints and tips* page. +what it names. It sits at the right, led by a bulb and closed by a small cross a click puts +it away with until your next visit. The keys row at the very foot is not a tip and never +changes. The whole list, what makes each one appear and disappear, and the **disable hints** +row on the Workspace tab that turns them off, are on the *hints and tips* page. ## What does pressing space twice do — space space does nothing now diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index 57c46aa4c9..db90cb20fe 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -2230,10 +2230,11 @@ readings of what you type: a search of everything home shows, or the first messa new conversation. (Until 2026-09-17 the box said `› say what you want done` and the promise opened the foot.) **The rule above it is a legend on home and nowhere else**, and it says what the box is a draft *for*: -`─ glm-5.3-flash:auto · ◇ asks ─── project: ~/codeaf` +`─ glm-5.3-flash:auto · ◇ asks ───` -— the model, a colon and effort, then approvals at the left; the project the next -conversation opens in sits at the far right. The arrow and effort badge are gone. A long +— the model, a colon and effort, then approvals at the left. The project the next +conversation opens in is `project: ` at the right end of the keys row under the box +(it stood at the rule's right until 2026-09-22). The arrow and effort badge are gone. A long project path keeps its root and truncates on the right. The chords that change them are on **the line under the box**, with home's own keys, because the lowest line is for keys on home as in a conversation: `alt+p project` walks all projects in the projects panel's order, `alt+e effort` diff --git a/internal/manual/chat/places.md b/internal/manual/chat/places.md index 81b67f0db9..bba991f9a1 100644 --- a/internal/manual/chat/places.md +++ b/internal/manual/chat/places.md @@ -197,18 +197,22 @@ The line over home's box is the same shape as the line over a conversation's own box: ``` -─ glm-5.3-flash:auto · ◇ asks ─── project: ~/src/parser + 💡 /ask answers right here without opening a conversation ✕ +─ glm-5.3-flash:auto · ◇ asks ────────────────────────────────────────────────────── › type to search or start something new -alt+p project · alt+e effort · alt+a approvals · alt+k chats · / commands +alt+p project · alt+e effort · alt+a approvals · / commands project: ~/src/parser ``` At the left it says **what model** answers, then a colon and **how hard it thinks** (the rung or `auto`, without a badge), and **what it runs without asking** (`◇` and `asks`, `guardian`, `YOLO` or `refuses` — the same words the approvals chip uses inside a conversation). The model is always bold and bright cyan, -on home and in conversations. At the far right, `project: ` names where the -next conversation opens; in a conversation it names that conversation's workspace. -Long paths truncate on the right, and the field disappears if there is no room. The bottom row names the -available project, effort and approval controls; the cells can also be pressed: +on home and in conversations. In a conversation the seam's far right names that +conversation's workspace; on home, `project: ` is at the right end of the **keys row +under the box** instead, naming where the next conversation opens (it left the rule on +2026-09-22). The keys keep their room: a long path truncates on the right, and the field +disappears if there is less than a word of room. The dim line above the rule, when there is +one, is a tip (see *hints and tips*). The bottom row names the available project, effort +and approval controls; the cells can also be pressed: | cell | chord | or | |---|---|---| diff --git a/internal/tui3/app.go b/internal/tui3/app.go index d91a544fa1..cd365c138c 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -2258,6 +2258,14 @@ type app struct { targetHover hoverKind targetFolderSpan hudSpan targetModelSpan hudSpan + // footRow is which row of the frame home's keys row was drawn on — the + // row [targetFolderSpan] is on since the project moved down to it + // (hometip.go) — and tipRow is the tip row above the rule, with + // tipCloseSpan the columns of its cross. Both are -1 on a frame that + // drew neither. + footRow int + tipRow int + tipCloseSpan hudSpan // targetEffortSpan and targetApprovalSpan are the rung's and the gate's // columns on that same line — the draft's twins of [app.seamEffortSpan] and // [app.seamApprovalSpan] (boxseam.go), recorded on the same bargain. diff --git a/internal/tui3/home.go b/internal/tui3/home.go index de8200e627..6aa072f094 100644 --- a/internal/tui3/home.go +++ b/internal/tui3/home.go @@ -3814,6 +3814,12 @@ func (a *app) homePress(x, y int) tea.Cmd { if a.homePhone() { return a.homePhonePress(x, y) } + // THE CROSS ON THE TIP ROW PUTS THE TIP AWAY (hometip.go). It is read + // first because its row carries no other door and moves no cursor. + if a.tipRow >= 0 && y == a.tipRow && a.tipCloseSpan.holds(x) { + a.noticeHomeDismiss() + return nil + } // A CLICK MOVES THE CURSOR, so it is one of the two gestures that can leave // a settled exchange behind ([app.sweepExchanges] is the other half of // [app.homeKey]'s own deferred sweep). diff --git a/internal/tui3/home_test.go b/internal/tui3/home_test.go index 029a88be20..bef53af80b 100644 --- a/internal/tui3/home_test.go +++ b/internal/tui3/home_test.go @@ -867,6 +867,11 @@ func TestHomesRestingFootIsTheDesignsSentence(t *testing.T) { // // The resting row adds the available draft controls without navigation hints. rest := strings.TrimSpace(ansi.Strip(lines[len(lines)-1])) + // THE PROJECT RIDES THE ROW'S RIGHT since 2026-09-22 (hometip.go), after + // the keys; the sentence under test is the keys. + if at := strings.LastIndex(rest, targetProjectLead); at >= 0 { + rest = strings.TrimSpace(rest[:at]) + } want := hintFit(dotted(homeOptionsWord, a.targetChordWords()), a.width-2) if rest != want || strings.Contains(rest, "↑↓ pick") || strings.Contains(rest, "enter open") { t.Fatalf("the resting hint reads %q, want %q", rest, want) diff --git a/internal/tui3/homedraft.go b/internal/tui3/homedraft.go index ff8c9794a2..d4467a483c 100644 --- a/internal/tui3/homedraft.go +++ b/internal/tui3/homedraft.go @@ -226,9 +226,10 @@ func (a *app) targetProject() string { return a.hostedPath(a.placeWord(tildePath(a.targetWhere(), a.tilde))) } -// targetLegend keeps model, effort and approvals at the left, with the -// project at the right. A long project gives up its right end first. -// Its click span is measured from that same layout, so it follows the text. +// targetLegend keeps model, effort and approvals at the left. The project +// used to stand at its right and is on the keys row under the box now +// (hometip.go); the three doors' click spans are measured from this layout, +// so they follow the text. func (a *app) targetLegend(width int, pal palette) (string, bool) { a.clearTargetSpans() if width < 1 { @@ -238,17 +239,16 @@ func (a *app) targetLegend(width int, pal palette) (string, bool) { return a.draftNoteRule(width, pal, note) } left, model, rung, gate := a.draftSeamLeft(legendRoom(width, "")) - right, project := seamProjectRight(left, "", a.targetProject(), width) - painted := a.paintSeamProject(right, project, a.targetHover == hoverSeamProject) - line, at, ok := a.legendLinePainted(left, right, painted, width, a.draftSeamPaint(pal, model, rung, gate)) + // THE PROJECT LEFT THE RULE FOR THE KEYS ROW on 2026-09-22 (hometip.go's + // [app.homeFootLine]), so the right of home's rule is bare and its door + // is recorded where the path is drawn now. A conversation's seam still + // names its workspace at the right (foot.go). + line, _, ok := a.legendLinePainted(left, "", "", width, a.draftSeamPaint(pal, model, rung, gate)) if !ok { return "", false } a.targetModelSpan = shiftIntoBorder(model) a.targetEffortSpan, a.targetApprovalSpan = shiftIntoBorder(rung), shiftIntoBorder(gate) - if project.pressable() { - a.targetFolderSpan = hudSpan{from: at + project.from, to: at + project.to} - } return line, true } diff --git a/internal/tui3/homefate_test.go b/internal/tui3/homefate_test.go index 9c9a1c9cfc..64f97651c8 100644 --- a/internal/tui3/homefate_test.go +++ b/internal/tui3/homefate_test.go @@ -124,9 +124,10 @@ func TestFolderAtHomeBrowsesForTheTargetAndPinsIt(t *testing.T) { if a.home.msg != "" { t.Fatalf("the project selection added a footer message: %q", a.home.msg) } - // THE RULE ABOVE THE BOX SAYS IT ON THE VERY NEXT FRAME. - if text := homeText(a); !strings.Contains(text, targetPathWord(a)) { - t.Fatalf("the rule does not name the folder that was just pinned:\n%s", text) + // THE KEYS ROW UNDER THE BOX SAYS IT ON THE VERY NEXT FRAME (hometip.go), read + // at a width where a temp-dir path is not cut. + if text := ansi.Strip(a.homeFootLine(400, a.pal)); !strings.Contains(text, targetPathWord(a)) { + t.Fatalf("the keys row does not name the folder that was just pinned:\n%s", text) } // AND NOTHING REACHED THE CONVERSATION BEHIND HOME. A pin is a decision about // a conversation that does not exist yet. diff --git a/internal/tui3/homeslash_test.go b/internal/tui3/homeslash_test.go index f7c882e728..3300af7a2a 100644 --- a/internal/tui3/homeslash_test.go +++ b/internal/tui3/homeslash_test.go @@ -293,12 +293,17 @@ func TestHomesRuleShortensTheProjectAfterItsRoot(t *testing.T) { if !strings.Contains(a.homeHint(), targetFolderKeyWord) { t.Fatalf("the foot does not carry the folder chord:\n%s", a.homeHint()) } - // A long path keeps its root and yields its tail before the model. + // A long path keeps its root on the keys row and yields its tail to the + // keys; the rule carries the model and never the path (hometip.go). a.target.where = "/tmp/" + strings.Repeat("nested/", 20) narrow, drew := a.targetLegend(80, a.pal) stripped := ansi.Strip(narrow) - if !drew || !strings.HasPrefix(stripped, "─ "+a.modelIdentity(a.model)) || !strings.Contains(stripped, "project: /tmp/") || !strings.Contains(stripped, "… ─") { - t.Fatalf("the model or project root was lost: %q", stripped) + if !drew || !strings.HasPrefix(stripped, "─ "+a.modelIdentity(a.model)) || strings.Contains(stripped, targetProjectLead) { + t.Fatalf("the model was lost or the project is still on the rule: %q", stripped) + } + foot := ansi.Strip(a.homeFootLine(80, a.pal)) + if !strings.Contains(foot, "project: /tmp/") || !strings.HasSuffix(foot, "…") { + t.Fatalf("the keys row lost the project root or its ellipsis: %q", foot) } } diff --git a/internal/tui3/hometip.go b/internal/tui3/hometip.go new file mode 100644 index 0000000000..1edce7308b --- /dev/null +++ b/internal/tui3/hometip.go @@ -0,0 +1,112 @@ +package tui3 + +import ( + "strings" + + "github.com/charmbracelet/x/ansi" + + "github.com/Agent-Field/codeaf/internal/tui2/tokens" +) + +// ── HOME'S TWO LOWEST ROWS, LAID OUT ───────────────────────────────────────── +// +// The foot of home is three rows: the tip, the rule, the keys. +// +// 💡 /ask answers right here without opening a conversation ✕ +// ─ glm-5.3-flash:auto · ◇ asks ─────────────────────────────────────────────────────────────── +// › type to search or start something new +// → options · alt+p project · alt+e effort · alt+a approvals · / commands project: ~/codeaf +// +// THE TIP IS RIGHT-ALIGNED OVER THE RULE, one cell in from the edge, directly +// above where the rule used to say the project (the owner's placing, +// 2026-09-22). It is led by a bulb and closed by a cross a pointer can press: +// the cross puts the tip away until home is next visited or its own clock +// brings the next one round ([app.noticeHomeDismiss]). +// +// THE PROJECT IS ON THE KEYS ROW NOW, right-justified, and it is the keys that +// keep their room: the path gives up its right end, one ellipsis, where the +// keys leave it no room for the whole, and goes entirely where they leave it +// less than a word. It is still the door onto the folder chooser it was on the +// rule (placemouse.go's [app.placeTargetPress]), so its columns are recorded +// where they are drawn, on [app.homeDoor]'s bargain. + +// homeTipLead is the bulb before a tip on home's row. +// +// IT IS AN EMOJI, AND THAT IS THE OWNER'S RULING (2026-09-22) against the +// vocabulary's own no-emoji-in-chrome law (internal/tui2/tokens's glyph.go): +// one bulb, on one row, on one place, asked for by name. It is not a slot in +// the vocabulary because the vocabulary refuses the emoji planes on purpose +// and its width gate would refuse this one; and it is not the icon law's to +// own, because the law owns the vocabulary's runes and no other. It measures +// two cells everywhere the renderer measures, and the row is laid out from +// that measurement rather than from a guess. +const homeTipLead = "💡" + +// homeTipGap is the cell between the bulb and the tip, and between the tip and +// its cross. +const homeTipGap = " " + +// homeTipFloor is the fewest cells of tip worth drawing beside the bulb and +// the cross: under it the row says nothing, because a bulb beside three +// letters and an ellipsis is a row that teaches nothing. +const homeTipFloor = 8 + +// homeFootPathFloor is the fewest cells of path worth drawing after +// `project: ` on the keys row — the root and an ellipsis, or nothing. +const homeFootPathFloor = 4 + +// homeTipLine lays the tip row out: the bulb, the tip, the cross, right-aligned +// to end one cell in from the right edge. It reports the cross's columns, for +// the press, and an empty line where the frame is too narrow for the row to +// say anything. +func (a *app) homeTipLine(tip string, width int, pal palette) (string, hudSpan) { + cross := pal.glyph(tokens.GFailed) + lead := homeTipLead + homeTipGap + tail := homeTipGap + cross + room := width - 1 - ansi.StringWidth(lead) - ansi.StringWidth(tail) + if room < homeTipFloor { + return "", hudSpan{} + } + tip = fit(tip, room) + pad := width - 1 - ansi.StringWidth(lead) - ansi.StringWidth(tip) - ansi.StringWidth(tail) + from := pad + ansi.StringWidth(lead) + ansi.StringWidth(tip) + span := hudSpan{from: from, to: from + ansi.StringWidth(tail)} + line := strings.Repeat(" ", pad) + lead + paintHint(tip, pal, pal.dim) + homeTipGap + pal.dim(cross) + return line, span +} + +// homeFootLine is home's keys row: the keys one cell in, fitted first, and the +// project right-justified in whatever they leave. It records the path's +// columns in [app.targetFolderSpan] — the same span the rule used to write — +// and clears them where the path does not fit. +func (a *app) homeFootLine(width int, pal palette) string { + a.targetFolderSpan = hudSpan{} + hint := hintFit(a.placeHint(), width-2) + line := " " + paintHint(hint, pal, pal.dim) + project := a.targetProject() + if project == "" { + return line + } + used := 1 + ansi.StringWidth(hint) + room := width - 1 - used - hudGap + lead := targetProjectLead + if room < ansi.StringWidth(lead)+homeFootPathFloor { + return line + } + path := fit(project, room-ansi.StringWidth(lead)) + text := lead + path + pad := width - 1 - used - ansi.StringWidth(text) + from := used + pad + ansi.StringWidth(lead) + a.targetFolderSpan = hudSpan{from: from, to: from + ansi.StringWidth(path)} + painted := a.paintSeamProject(text, hudSpan{from: ansi.StringWidth(lead), to: ansi.StringWidth(text)}, + a.targetHover == hoverSeamProject) + return line + strings.Repeat(" ", pad) + painted +} + +// noticeHomeDismiss is the cross on home's tip row: the tip goes away until +// the next visit to home or the next turn of its own clock, and nothing is +// written down — a tip put away is not a tip learned, so it is not retired. +func (a *app) noticeHomeDismiss() { + a.notices.homeHidden = true + a.touch() +} diff --git a/internal/tui3/hometiplayout_test.go b/internal/tui3/hometiplayout_test.go new file mode 100644 index 0000000000..d7c82869f9 --- /dev/null +++ b/internal/tui3/hometiplayout_test.go @@ -0,0 +1,195 @@ +package tui3 + +import ( + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" + + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/tui2/tokens" +) + +// ── THE TIP ROW'S SHAPE, THE KEYS ROW'S RIGHT, AND THE ROW THAT SILENCES THEM ── + +// homeFrameLines is home's frame as a reader sees it, one string per row. +func homeFrameLines(a *app) []string { + width, height := a.size() + lines, _, _, _ := a.homeFrame(width, height) + out := make([]string, len(lines)) + for i, line := range lines { + out[i] = ansi.Strip(line) + } + return out +} + +// The tip is right-aligned over the rule, led by the bulb and closed by the +// cross, ending one cell in from the edge — and the cross puts it away until +// the next visit. +func TestHomeTipIsRightAlignedWithABulbAndACrossThatPutsItAway(t *testing.T) { + lab := newHomeLab(t) + a := lab.door("") + a.showPage(pageHome) + tip := a.noticeHomeHint() + if tip == "" { + t.Fatal("home opened with no tip") + } + width, _ := a.size() + rows := homeFrameLines(a) + if a.tipRow < 0 || a.tipRow >= len(rows) { + t.Fatalf("the draw recorded the tip on row %d of %d", a.tipRow, len(rows)) + } + row := rows[a.tipRow] + cross := a.pal.glyph(tokens.GFailed) + if !strings.HasSuffix(row, homeTipLead+homeTipGap+tip+homeTipGap+cross) { + t.Fatalf("the tip row does not end with the bulb, the tip and the cross: %q", row) + } + if got := ansi.StringWidth(row); got != width-1 { + t.Fatalf("the tip row measures %d cells on a %d-cell frame, want %d", got, width, width-1) + } + if !strings.HasPrefix(row, " ") { + t.Fatalf("the tip row is not right-aligned: %q", row) + } + // The rule is the very next row. + if a.targetRow != a.tipRow+1 { + t.Fatalf("the tip is on row %d and the rule on row %d; they should be neighbours", a.tipRow, a.targetRow) + } + // THE CROSS. A press on it puts the tip away; a press beside it does not. + if !a.tipCloseSpan.pressable() { + t.Fatal("the draw recorded no columns for the cross") + } + a.homePress(a.tipCloseSpan.from-4, a.tipRow) + if a.noticeHomeHint() != tip { + t.Fatal("a press on the tip's words put it away") + } + a.homePress(a.tipCloseSpan.from, a.tipRow) + if got := a.noticeHomeHint(); got != "" { + t.Fatalf("the cross did not put the tip away: %q", got) + } + if strings.Contains(homeText(a), tip) { + t.Fatal("the tip is still drawn after its cross was pressed") + } + if a.notices.retired(a.notices.current[slotHome]) { + t.Fatal("putting a tip away retired it") + } + // The next visit brings a tip back. + a.showPage(pageHome) + if a.noticeHomeHint() == "" { + t.Fatal("the next visit brought no tip back") + } +} + +// A frame too narrow for the bulb, a word and the cross draws no tip row at +// all rather than a bulb beside nothing. +func TestHomeTipRowSaysNothingOnAFrameTooNarrowForIt(t *testing.T) { + lab := newHomeLab(t) + a := lab.door("") + a.showPage(pageHome) + line, span := a.homeTipLine("/ask answers right here", 12, a.pal) + if line != "" || span.pressable() { + t.Fatalf("a 12-cell frame drew a tip row: %q", line) + } + line, span = a.homeTipLine("/ask answers right here without opening a conversation", 40, a.pal) + if line == "" || !span.pressable() { + t.Fatal("a 40-cell frame drew no tip row") + } + if got := ansi.StringWidth(ansi.Strip(line)); got != 39 { + t.Fatalf("the cut tip row measures %d cells, want 39", got) + } +} + +// The project is at the right end of the keys row, and the keys keep their +// room: the path is cut on the right where they leave it too little, and gone +// where they leave it less than a word. It is still the folder door. +func TestHomeKeysRowCarriesTheProjectAtItsRight(t *testing.T) { + lab := newHomeLab(t) + a := lab.door("") + a.showPage(pageHome) + width, _ := a.size() + rows := homeFrameLines(a) + if a.footRow != len(rows)-1 { + t.Fatalf("the keys row is recorded on row %d of %d", a.footRow, len(rows)) + } + foot := rows[a.footRow] + project := a.targetProject() + if project == "" { + t.Fatal("the lab's home has no project to name") + } + if !strings.HasSuffix(foot, targetProjectLead+project) { + t.Fatalf("the keys row does not end with the project: %q", foot) + } + if got := ansi.StringWidth(foot); got != width-1 { + t.Fatalf("the keys row measures %d cells on a %d-cell frame, want %d", got, width, width-1) + } + if !strings.HasPrefix(foot, " "+homeOptionsWord) { + t.Fatalf("the keys row does not begin with the keys: %q", foot) + } + // AND THE RULE NO LONGER NAMES IT. + if strings.Contains(rows[a.targetRow], targetProjectLead) { + t.Fatalf("the rule still carries the project: %q", rows[a.targetRow]) + } + // THE DOOR. The path's columns are the folder door, on the keys row. + if !a.targetFolderSpan.pressable() { + t.Fatal("the keys row recorded no columns for the path") + } + if got := ansi.Cut(foot, a.targetFolderSpan.from, a.targetFolderSpan.to); got != project { + t.Fatalf("the recorded span holds %q, want the path %q", got, project) + } + if _, took := a.placeTargetPress(a.targetFolderSpan.from, a.footRow); !took { + t.Fatal("a press on the path was not taken as the folder door") + } + if _, took := a.placeTargetPress(a.targetFolderSpan.from, a.targetRow); took { + t.Fatal("a press on the rule where the path used to be still opened the door") + } + + // THE KEYS KEEP THEIR ROOM. A long path is cut on the right, one ellipsis, + // and the keys are whole. + a.target.where = "/tmp/" + strings.Repeat("nested/", 30) + keys := hintFit(a.placeHint(), width-2) + cut := ansi.Strip(a.homeFootLine(width, a.pal)) + if !strings.HasPrefix(cut, " "+keys) { + t.Fatalf("a long path cost the keys a clause: %q", cut) + } + if !strings.Contains(cut, targetProjectLead+"/tmp/nested/") || !strings.HasSuffix(cut, "…") { + t.Fatalf("a long path was not cut on the right with its root kept: %q", cut) + } + if got := ansi.StringWidth(cut); got != width-1 { + t.Fatalf("the cut keys row measures %d cells, want %d", got, width-1) + } + // And where the keys leave less than a word, the path goes entirely. + narrow := ansi.Strip(a.homeFootLine(ansi.StringWidth(keys)+2+hudGap+len(targetProjectLead)+2, a.pal)) + if strings.Contains(narrow, targetProjectLead) { + t.Fatalf("a keys row with no room for a word of path still drew the label: %q", narrow) + } + if a.targetFolderSpan.pressable() { + t.Fatal("a keys row with no path left a door recorded") + } +} + +// The row that silences the tips is `disable hints` on the Workspace tab, off +// by default, and it reads the other way up from the key underneath it. +func TestDisableHintsIsAWorkspaceRowThatReadsTheOtherWayUp(t *testing.T) { + a, dir := sheetApp(t) + a.openSettings() + cursorTo(t, a, config.KeyHints) + item := a.sheet.items[a.sheet.cursor] + if item.meta.tab != tabWorkspace || item.meta.label != "disable hints" { + t.Fatalf("the hints row is %q on the %s tab, want \"disable hints\" on Workspace", item.meta.label, item.meta.tab) + } + if got := item.row.Value(); got != "off" { + t.Fatalf("a fresh profile reads %q, want off (hints shown)", got) + } + if !config.HintsAt(dir) { + t.Fatal("a fresh profile has hints off underneath") + } + drive(t, a, key("enter")) + if config.HintsAt(dir) { + t.Fatal("flipping disable hints on did not silence the tips") + } + a.closeSettings() + a.openSettings() + cursorTo(t, a, config.KeyHints) + if got := a.sheet.items[a.sheet.cursor].row.Value(); got != "on" { + t.Fatalf("after the flip the row reads %q, want on", got) + } +} diff --git a/internal/tui3/notice.go b/internal/tui3/notice.go index 058ab3f2bc..7bdb769a3a 100644 --- a/internal/tui3/notice.go +++ b/internal/tui3/notice.go @@ -640,6 +640,10 @@ type noticeBoard struct { // homeAt is when home's row last changed hands, or zero when it never // has; [homeHintEvery] is measured from it by the beat. homeAt time.Time + // homeHidden is the cross on the row having been pressed: the tip standing + // is not drawn until the next rotation, which clears it. It is this + // session's and never the ledger's — putting a tip away is not using it. + homeHidden bool } // bareNoticeBoard is a board with nothing behind it: no ledger on disk, no @@ -896,6 +900,7 @@ func (a *app) noticeHomeRotate() { *b = bareNoticeBoard() } b.homeAdvance = true + b.homeHidden = false if a.noticeFill(slotHome) { b.save() } @@ -923,7 +928,7 @@ func (a *app) noticeHomeBeat() { func (a *app) noticeHomeHint() string { b := &a.notices id := b.current[slotHome] - if id == "" || !b.enabled || !a.noticeHomeQuiet() { + if id == "" || !b.enabled || b.homeHidden || !a.noticeHomeQuiet() { return "" } for _, n := range notices { diff --git a/internal/tui3/notice_test.go b/internal/tui3/notice_test.go index 4a4bd5a2f9..b724b73cd7 100644 --- a/internal/tui3/notice_test.go +++ b/internal/tui3/notice_test.go @@ -615,7 +615,7 @@ func TestTheHintsRowSilencesTheSlot(t *testing.T) { a.openSettings() for i, tab := range settingTabs { - if tab == tabDisplay { + if tab == tabWorkspace { a.sheet.tab = i } } diff --git a/internal/tui3/pages.go b/internal/tui3/pages.go index 6875ace9da..6ce0c79a13 100644 --- a/internal/tui3/pages.go +++ b/internal/tui3/pages.go @@ -1405,8 +1405,19 @@ func placeFrameWithBar(a *app, width, height int, // way — the foot is one height with a tip and without — and it is dim, // one cell in, in the grammar every hint on this surface keeps: the key or // the command, then what it does. + // + // IT IS RIGHT-ALIGNED, led by a bulb and closed by a cross (hometip.go's + // [app.homeTipLine]), and the cross's columns are recorded as the line is + // laid out, published below the clamp with the rule's own row. + tipTop := -1 + a.tipCloseSpan = hudSpan{} if tip := a.noticeHomeHint(); hasBox && tip != "" { - add(" "+pal.dim(fit(tip, width-2)), nil) + if line, span := a.homeTipLine(tip, width, pal); line != "" { + a.tipCloseSpan, tipTop = span, len(lines) + add(line, nil) + } else { + add("", nil) + } } else { add("", nil) } @@ -1533,6 +1544,10 @@ func placeFrameWithBar(a *app, width, height int, default: if msg, ok := a.placeMsgLine(width); ok { add(msg, nil) + } else if hasBox { + // HOME'S KEYS ROW CARRIES THE PROJECT AT ITS RIGHT (hometip.go's + // [app.homeFootLine]): the keys first, and the path in what they leave. + add(a.homeFootLine(width, pal), nil) } else { add(" "+paintHint(hintFit(a.placeHint(), width-2), pal, pal.dim), nil) } @@ -1574,12 +1589,29 @@ func placeFrameWithBar(a *app, width, height int, case targetTop > 0: targetTop = -1 } + // AND THE TIP ROW OVER IT, by the same arithmetic. + switch { + case tipTop >= 1+removed: + tipTop -= removed + case tipTop > 0: + tipTop = -1 + } } a.boxRow, a.boxRows = boxTop, boxHeight a.targetRow = targetTop if targetTop < 0 { a.clearTargetSpans() } + a.tipRow = tipTop + if tipTop < 0 { + a.tipCloseSpan = hudSpan{} + } + // THE KEYS ROW IS THE LAST ROW, and the clamp keeps the last rows, so it + // is on every frame that has a box at all. + a.footRow = -1 + if hasBox { + a.footRow = len(lines) - 1 + } for len(lines) < height { add("", nil) } diff --git a/internal/tui3/placemouse.go b/internal/tui3/placemouse.go index 063aa0e64d..2fd748e45b 100644 --- a/internal/tui3/placemouse.go +++ b/internal/tui3/placemouse.go @@ -251,6 +251,12 @@ func (a *app) placeTargetPress(x, y int) (tea.Cmd, bool) { if !a.placeHasDraft() || a.composer.open || a.target.pick.open { return nil, false } + // THE PROJECT IS ON THE KEYS ROW, and it is the same door it was on the + // rule (hometip.go's [app.homeFootLine] records the span). + if a.footRow >= 1 && y == a.footRow && a.targetFolderSpan.holds(x) { + a.moveTarget() + return nil, true + } if a.targetRow < 1 || y != a.targetRow { return nil, false } @@ -258,9 +264,6 @@ func (a *app) placeTargetPress(x, y int) (tea.Cmd, bool) { case a.targetModelSpan.holds(x): a.openTargetPicker() return nil, true - case a.targetFolderSpan.holds(x): - a.moveTarget() - return nil, true case a.targetEffortSpan.holds(x): return a.cycleTargetEffort(), true case a.targetApprovalSpan.holds(x): diff --git a/internal/tui3/projectseam.go b/internal/tui3/projectseam.go index dee5240663..7cc48139fd 100644 --- a/internal/tui3/projectseam.go +++ b/internal/tui3/projectseam.go @@ -44,11 +44,12 @@ func seamModelPaint(pal palette, text string, hovered bool) string { // when the pointer leaves home or crosses onto the tab bar or another field. func (a *app) hoverDraftSeam(x, y int) { next := hoverNothing - if a.placeHasDraft() && !a.composer.open && !a.target.pick.open && a.targetRow > 0 && y == a.targetRow { + if a.placeHasDraft() && !a.composer.open && !a.target.pick.open { switch { - case a.targetModelSpan.holds(x): + case a.targetRow > 0 && y == a.targetRow && a.targetModelSpan.holds(x): next = hoverStatusModel - case a.targetFolderSpan.holds(x): + case a.footRow > 0 && y == a.footRow && a.targetFolderSpan.holds(x): + // The path is on the keys row now (hometip.go). next = hoverSeamProject } } diff --git a/internal/tui3/settings.go b/internal/tui3/settings.go index d3387210ec..0e5c016205 100644 --- a/internal/tui3/settings.go +++ b/internal/tui3/settings.go @@ -634,10 +634,16 @@ var settingUI = map[string]settingMeta{ about: "ctrl+tab switches on the press where the terminal can send it. " + "Off, it waits for enter. alt+k always opens the list and waits for your choice.", }, + // THE ROW IS ON WORKSPACE AND READS THE OTHER WAY UP (the owner's placing, + // 2026-09-22): `disable hints`, off by default, on to silence them. The key + // underneath is still `ui.hints` meaning shown — a persisted identifier keeps + // its bytes — and internal/config's row inverts on the way in and out, so + // this door and `codeaf config` say the same word. config.KeyHints: { - tab: tabDisplay, label: "hints", widget: widgetToggle, - about: "one-line tips above the box until you have used what each one " + - "teaches. Off silences them, and what's-new lines with them.", + tab: tabWorkspace, label: "disable hints", widget: widgetToggle, + about: "on silences the one-line tips — home's row above the rule and the " + + "keys row's — and what's-new lines with them. Off shows each until you " + + "have used what it teaches.", }, config.KeySplitPct: { tab: tabDisplay, label: "chat width", widget: widgetText, From 9948337924fc522c35c9d959c1258b43eaa119f0 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 Date: Tue, 22 Sep 2026 08:36:59 -0400 Subject: [PATCH 04/39] chat: @ works on home, /attach takes any path and opens the browser bare, /image is gone, two tips swapped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewing the tips against the surface turned up four bugs, all fixed here. The @ list on home. Home's box never opened the completion every conversation's box has; the hint promised it. homeat.go binds the same completion to home's box, drawn as rows of home's column like the slash list, walking the folder the next conversation opens in, offering files and folders and never tasks. Enter puts the path in after the @, a picture comes out of the sentence and onto home's tray, esc closes the list, and the command list wins when both could open. A quoted or escaped path. /attach '/Users/me/Screenshot 2026-09-18 at 1.35.20 PM.png' — the shape Finder and a terminal drop hand you — answered `no such file`. resolvePath now reads a path the way the shell it was copied from would: matching quotes come off, a backslash before a space is the space, and anything else is left as typed. /attach bare on home opens the browser aimed at the next conversation's folder, as a bare /folder does, and a file chosen there lands on home's tray; it used to say `type the path after /attach`. /image is gone. It was a second word for /attach that took only a picture and refused everything else; /attach already told a picture from a file by its name. The command table, the dispatch, home's gate and the path completion's argument list no longer know the word, and typing it is answered as every unknown word is. Two tips changed. `alt+enter sends what you typed off as a task` is cut: on home the chord asks rather than sending a task. `ctrl+r spells out …` is cut: the chord works only in a conversation and only over a making-shaped sentence, so on home it named a key that did nothing. `/manual answers any question about codeaf from its own manual` and `ctrl+shift+t reopens the tab you just closed` take the seats, each retired by its own seam, and the table stays at thirty. Eight manual pages stop naming /image and describe the list on home, the quoted-path rule and the bare /attach browser. Co-Authored-By: Claude Fable 5.1 --- internal/manual/chat/attaching-files.md | 52 +++--- internal/manual/chat/choosing-a-folder.md | 2 +- internal/manual/chat/commands.md | 36 ++-- internal/manual/chat/hints-and-tips.md | 14 +- internal/manual/chat/home.md | 16 +- internal/manual/chat/keys.md | 42 +++-- .../manual/chat/running-on-another-machine.md | 8 +- internal/manual/chat/what-i-can-do.md | 2 +- internal/manual/chat_test.go | 2 + internal/tui3/app.go | 19 +- internal/tui3/attach.go | 56 +++--- internal/tui3/attach_test.go | 27 ++- internal/tui3/attachfile_test.go | 6 +- internal/tui3/commands.go | 1 - internal/tui3/export_test.go | 2 +- internal/tui3/files.go | 8 +- internal/tui3/folderplace.go | 15 +- internal/tui3/head_test.go | 6 +- internal/tui3/home.go | 44 ++++- internal/tui3/homeat.go | 173 ++++++++++++++++++ internal/tui3/homeat_test.go | 171 +++++++++++++++++ internal/tui3/homefate_test.go | 25 ++- internal/tui3/homephone.go | 3 + internal/tui3/homeslash.go | 20 +- internal/tui3/host_test.go | 2 +- internal/tui3/imagepaste_test.go | 10 +- internal/tui3/notice.go | 35 ++-- internal/tui3/notice_test.go | 6 +- internal/tui3/place_home.go | 3 +- internal/tui3/spellout.go | 3 - internal/tui3/tabreopen.go | 3 + 31 files changed, 612 insertions(+), 200 deletions(-) create mode 100644 internal/tui3/homeat.go create mode 100644 internal/tui3/homeat_test.go diff --git a/internal/manual/chat/attaching-files.md b/internal/manual/chat/attaching-files.md index 9debcbf942..207767cbb2 100644 --- a/internal/manual/chat/attaching-files.md +++ b/internal/manual/chat/attaching-files.md @@ -82,9 +82,11 @@ more than one — and `enter` does exactly what it says. Over `--host`, the shee machine you are sitting at and selected files travel with the message. "Choosing a folder" is the full account of that sheet, its keys and its preview. -Path rules are `/image`'s: `~` is your home directory, a bare name is under the directory -this conversation is about, and an absolute path is left alone. Tab completes the path as -you type it. Over `--host`, that completion walks the machine you are sitting at, because +Path rules: `~` is your home directory, a bare name is under the directory this +conversation is about, an absolute path is left alone, and a path wrapped in quotes or with +its spaces backslashed — `/attach '/Users/me/Screenshot 2026-09-18 at 1.35.20 PM.png'`, the +shape Finder and a terminal drop hand you — is read as the one path it is (until +2026-09-22 that was answered `no such file`). Tab completes the path as you type it. Over `--host`, that completion walks the machine you are sitting at, because those are the bytes `/attach` is about to send. The file lands on the tray as its own chip — `▤ server.log`, or `+ server.log` on a @@ -253,7 +255,7 @@ local only when its distro is this WSL distro. `/mnt` is WSL's default automount root. If `[automount] root` in `/etc/wsl.conf` names another root, codeaf uses that instead: `root = /drives` makes `c:/Users/…` read from -`/drives/c/Users/…`. This applies to a drag, a pasted path, `/attach`, `/image`, `/export`, +`/drives/c/Users/…`. This applies to a drag, a pasted path, `/attach`, `/export`, and a local copy destination chosen in `/files` because all use the same path reading. ## I copied a screenshot and pasted it — nothing happened @@ -271,7 +273,7 @@ only in the clipboard, save it as a file first. ## A drop into a box that already holds a command -**It stays text, and that is on purpose.** Type `/attach ` or `/image ` first and then +**It stays text, and that is on purpose.** Type `/attach ` first and then drop the file: the path is the command's argument, and turning it into `[image #1]` would break the one line on this surface whose whole job is to take a path. `enter` then runs the command and the file lands on the tray by that road instead. @@ -337,7 +339,7 @@ swept away while the transcript still refers to it. Yes — this is what `/attach` does over `--host`, and it is the point of it. The path you type is anchored to **this** machine, the one you are sitting at, exactly the -way `/image` and the `@` completion are. You are naming a file on your own laptop. Its +way the `@` completion is. You are naming a file on your own laptop. Its bytes travel with the message, and the far machine writes them down under that conversation's `attachments/` folder before the turn opens. @@ -395,8 +397,8 @@ The numbers come from the wire, not from taste: a whole message travels as one l line may weigh 64MB, and bytes inside it cost a third more than the file does. 16MB per file leaves room for two large ones, a screenshot and the sentence they came with. -Pictures are counted separately and have their own ceiling of **10MB each** — see the -`/image` refusals. +Pictures are counted separately and have their own ceiling of **10MB each** — see *What +codeaf says when a picture is refused* on the keys page. ## Every refusal /attach can give you @@ -444,10 +446,13 @@ one to. Dropping a folder on the window still refuses with the old **Home has a tray of its own and `/attach ` fills it.** No conversation is opened for it: the chip appears above home's box, home says `attached · server.log · rides with the next conversation`, and the file is attached to the -first message of whatever conversation you start next. `/image ` is the same for a -picture, and a drop or a paste onto home does it with no command at all. +first message of whatever conversation you start next. A picture goes the same way, and a +drop or a paste onto home does it with no command at all. -**A bare `/attach` there asks for the path** — `type the path after /attach · or drop the file +**A bare `/attach` there opens the browser**, the same sheet a bare `/folder` opens, aimed at +the folder the next conversation opens in; a file chosen on it lands on home's tray and a +folder chosen on it becomes that folder. (Until 2026-09-22 it answered `type the path after +/attach · or drop the file here` — rather than opening the browser. `/folder` is the browser on local home, and it is aimed at which folder the next conversation opens in (see "Choosing a folder"). Over `--host`, `/folder` says why this machine's folder cannot be that far conversation's folder. @@ -464,23 +469,14 @@ engine: an attached file arrived with no name engine: "../../etc/passwd" is a path and not a name — an attachment names itself and the engine chooses where it goes ``` -## Attaching a picture is a different thing +## Attaching a picture is a different thing — is there an /image command -`/image ` is the door for a picture, and a picture travels **as content** so it can -actually be looked at. - -You do not have to remember which word is which. **A picture handed to `/attach` is still -treated as a picture** — it goes on the tray as `▣ #1 shot.png`, gets its `[image #1]` -token in your sentence, and is looked at rather than read. png, jpeg, webp and gif are the -five codeaf accepts. - -The reverse is not true: `/image` refuses anything that is not one of those five, with - -``` - is not a picture · png, jpeg, webp and gif are -``` - -so `/attach` is the general word and `/image` is the specific one. +There is one word, `/attach`, and it tells a picture from a file by the name: **a picture +handed to `/attach` is treated as a picture** — it goes on the tray as `▣ #1 shot.png`, gets +its `[image #1]` token in your sentence, and travels **as content** so it can actually be +looked at rather than read. png, jpeg, webp and gif are the five codeaf accepts; anything +else is a file. There is no `/image` command: until 2026-09-22 it was a second word that +took only pictures and refused the rest, and it is gone. On the tray the two are told apart by their own glyph — `▣ #1 shot.png` for a picture, `▤ server.log` for a file — and by the number, which only a picture carries. In the @@ -499,7 +495,7 @@ left in the box as text; it is not any more, because over `--host` a path in a s names a file the far machine has never seen, and a chip is what makes the bytes travel. To keep a dropped path as *text* — to talk about a path rather than send the file — type -`/attach ` or `/image ` first and drop onto that line, or write the path yourself after +`/attach ` first and drop onto that line, or write the path yourself after some words. A box that already begins with `/` keeps the path as the command's argument. Over `--host` the difference matters more than it looks. A path left as plain text is a diff --git a/internal/manual/chat/choosing-a-folder.md b/internal/manual/chat/choosing-a-folder.md index 976a79b4ae..a89cf63f12 100644 --- a/internal/manual/chat/choosing-a-folder.md +++ b/internal/manual/chat/choosing-a-folder.md @@ -697,7 +697,7 @@ Choosing a folder inserts its path into your sentence exactly the way choosing a the whole thing. It does **not** open the picker and does not add the folder to the ones this conversation is about; it is text in your message, and the model resolves it. -The same list opens after `/attach `, `/image ` and `/export ` when you press `tab`, so the +The same list opens after `/attach ` and `/export ` when you press `tab`, so the folders are offered there too. ## Every refusal /folder can give you diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index e07895f4f3..7367319ef3 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -39,7 +39,7 @@ The same token rules apply on home and in conversations: list; moving the caret back into the command word opens it again. - The entire token must contain command-name characters. A further slash, a dot or a backslash makes it a path rather than a command token, even with the caret midway - through it. `/tmp/project` and `/image.png` therefore leave the list closed. + through it. `/tmp/project` and `/shot.png` therefore leave the list closed. A partial path such as `/tmp` is still indistinguishable from an unknown command word: it shows `no commands match` until another slash or path punctuation makes the intent @@ -156,7 +156,6 @@ Canonical word, the other words it answers to, its argument form, and what it do |---|---|---|---| | `/model` | — | — | opens the model picker | | `/model` | — | `` | switches the model to that slug | -| `/image` | — | `` | attaches a picture; tab completes the path | | `/settings` | `/set`, `/config` | — | opens the fullscreen settings panel (also ctrl+,) | | `/connect` | `/connections` | — | opens the connection panel; its `models` group holds model services, followed by connected accounts | | `/new` | `/clear`, `/clean`, `/reset` | — | closes this session and starts a fresh one | @@ -434,30 +433,29 @@ answers out loud, exactly: your terminal already has the pointer — drag to select. ``` -## /image — attach a picture +## /image — attach a picture, and why there is no /image command any more -`/image ` attaches a picture to your next message. Use it for a picture that is not -under this directory, or one the `@` completion walk does not reach. +There is no `/image` command. Until 2026-09-22 it was a second word for `/attach` that +took only a picture and refused everything else; `/attach ` does the whole job now. +A picture handed to it lands on the tray as `▣ #1 name.png` and **its `[image #1]` token +is appended to your sentence when you press `enter`**, so you can refer to it by number the +same way you would one you dragged in. Anything else lands as a file. Typing `/image` is +answered the way every unknown word is: `there is no command called /image · / lists them`. Path rules: `~` is your home directory, a bare name is under the directory this -conversation is about, and an absolute path is left alone. Over `--host` the path is -anchored to **this** machine — the picture is on the laptop you are sitting at, and its -bytes travel with the message. +conversation is about, an absolute path is left alone, and **a path in quotes, or with +its spaces backslashed** — the shape Finder and a terminal drop hand you — is read as the +one path it is. Over `--host` the path is anchored to **this** machine — the picture is on +the laptop you are sitting at, and its bytes travel with the message. -A full attachment tray does not stop the command: `/image` adds a second picture rather -than sending the first. +A full attachment tray does not stop the command: a second picture is a second chip rather +than a send. Dragging or pasting a file over a line that already starts with `/` leaves +the path as text, so `/attach ` still takes the path you dropped on it. -The picture lands on the tray as `▣ #1 name.png` and **its `[image #1]` token is appended -to your sentence when you press `enter`**, so you can refer to it by number the same way -you would one you dragged in. Dragging or pasting a file over a line that already starts -with `/` leaves the path as text, so `/image ` still takes the path you dropped on it. - -Refusals, exactly as written: +Refusals, exactly as written on the attaching-files page: ``` -/image takes a path · try /image shot.png - is not a picture · png, jpeg, webp and gif are -no such picture: +no such file: is already attached ``` diff --git a/internal/manual/chat/hints-and-tips.md b/internal/manual/chat/hints-and-tips.md index 1db3ae71d6..9115778b41 100644 --- a/internal/manual/chat/hints-and-tips.md +++ b/internal/manual/chat/hints-and-tips.md @@ -75,8 +75,6 @@ disagree), so a tip you saw is on it word for word. - `/ask answers right here without opening a conversation` — home only, whenever home's ask door is there. Retired the first time `/ask` or `alt+enter` sends something from home. -- `alt+enter sends what you typed off as a task` — home only, on the same terms and retired - by the same gesture. - `/task starts work you can walk away from` — conversation only, after the first exchange. Retired when `/task` is typed, bare or with a brief. - `ctrl+enter sends your message as something to keep true` — both. Retired when a standing @@ -84,8 +82,8 @@ disagree), so a tip you saw is on it word for word. - `/standing keeps something always true` — both, once this directory has three or more earlier conversations. Retired by the same gesture; it is the quietest and yields to every other in a conversation. -- `ctrl+r spells out what your sentence is taken to mean` — both, where the chord works. - Retired the first time you press it. +- `/manual answers any question about codeaf from its own manual` — both. Retired when + `/manual` is typed, bare, with a page or with a question. **Files and context** @@ -93,7 +91,7 @@ disagree), so a tip you saw is on it word for word. list opens. - `/attach sends a file along with your message` — both. Retired when a file goes on the tray by path or the file browser opens. -- `/image attaches a picture, or paste a screenshot in` — both. Retired by the same gesture. +- `/attach takes a picture too, or paste a screenshot in` — both. Retired by the same gesture. - `/folder picks the folder codeaf works in` — both. Retired when the folder chooser opens, from a conversation or aimed at home's target. - `/export writes this whole conversation to a file` — conversation only, after two @@ -129,6 +127,8 @@ disagree), so a tip you saw is on it word for word. - `ctrl+t starts a fresh chat in this folder` — both. Retired when the new-chat page opens. - `alt+1 to alt+7 jump straight to a place` — both. Retired the first time a place chord reaches one. +- `ctrl+shift+t reopens the tab you just closed` — both. Retired the first time the chord + is pressed, on a terminal that can send it. - `ctrl+. sees every task this project has run` — both, after the first task starts. Retired when you open the task page, by `ctrl+.` or `/history`. - `/resume opens an earlier conversation` — both, when you start in a directory that @@ -152,7 +152,9 @@ over the cost tip, the cost tip over the task page tip — and the other waits i home nothing wins: every tip that is true for you has its turn, in the order above. `/ shows every command` used to be one of these. It is gone because both keys rows now say -`/ commands` outright, so there was nothing left to teach. +`/ commands` outright, so there was nothing left to teach. Two more were cut on 2026-09-22: +an `alt+enter` tip that promised a task where the chord asks, and a `ctrl+r` tip for a +chord that works only in a conversation and only over a making-shaped sentence. ## Turn off hints — stop showing tips, disable the hints, the disable hints row diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index ae2d0e986a..1364213a81 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -1259,7 +1259,7 @@ one behind your back. This is every fate, in the words the drop-up draws them in | **`next conversation's folder`** | `/folder` `/place` `/dir` · `/folder ` | Opens the folder browser, **aimed at the next conversation**. Picking a folder pins it — `project: ~/src/parser` on the seam above the box shows the selection, with no duplicate footer message. | | **`opens the page`** | `/settings` `/set` `/config` · `/home` · `/search` · `/spend` · `/standing` · `/memory` `/memories` · `/history` · `/task` (bare) | A place replaces a place, exactly as before. | | **`this list is /resume`** | `/resume` `/sessions` | Says `this list is /resume · enter opens a row` — home *is* that list. | -| **`onto home's tray`** | `/attach ` · `/image ` | The file rides on home's own tray into the conversation you open next. Home says `attached · notes.md · rides with the next conversation`. A bare `/attach` says `type the path after /attach · or drop the file here`. | +| **`onto home's tray`** | `/attach ` | The file — or picture — rides on home's own tray into the conversation you open next. Home says `attached · notes.md · rides with the next conversation`. A bare `/attach` opens the browser aimed at the next conversation's folder, and a file chosen there lands on the tray. | | **`opens a conversation here first`** | `/files` · `/crew` (bare) · `/permissions` `/perms` · `/connect` · `/harness` · `/subharness` · `/copy` · `/select` · `/rewind` `/undo` `/back` · `/compact` · `/export` `/save` · `/standing ` · `/task ` | Opens a conversation at the target — the folder and model on the rule above the box — then runs there. Home closes, exactly as `enter` closes it. | | **`answers here`** | `/help` · `/manual` · `/status` · `/cost` · `/cache` · `/budget` · `/crew ` · `/debug` · `/stop` · `/remember` · `/forget` · a word nobody defined | Answers with a note, and the first line of that note is put on home's own line under the box. `there is no command called /pricing · / lists them` is now something you can read. | | **`runs on the conversation behind home`** | `/land` · `/land ` · `/workspace ` | Acts on the conversation this window is holding behind the screen — not on the one `enter` would open — and its answer is echoed onto home's line. | @@ -1275,7 +1275,7 @@ description gives way first, whole, and what `enter` will do stays on the row. conversation.** The chip appears on the row above home's box, and the line under it says `attached · server.log · rides with the next conversation`. When you then type a sentence and press `enter`, the conversation that opens has the file already attached to its first -message. `/image ~/shots/shot.png` is the same road for a picture. +message. `/attach ~/shots/shot.png` is the same road for a picture. **A drop does the same thing without a command.** Drag a file onto the window while home is up and it lands on the same tray. So does a paste. @@ -1392,6 +1392,18 @@ it away with until your next visit. The keys row at the very foot is not a tip a changes. The whole list, what makes each one appear and disappear, and the **disable hints** row on the Workspace tab that turns them off, are on the *hints and tips* page. +## Typing @ on home — does the @ file list work on home, complete a path into home's box + +Yes, since 2026-09-22. Type `@` and a letter or two into home's box and the same list a +conversation's box opens appears in home's column: files and folders under the folder the +next conversation opens in (the one on the rule), ranked as you type, `folder` and `img` +tags on the right. `↑`/`↓` pick, `enter` puts the path into your sentence after the `@`, +and choosing a picture takes the half-typed token out and puts the picture on home's tray +instead, saying `attached · shot.png · rides with the next conversation`. `esc` closes the +list and leaves the word alone. Tasks are not on this list — a task pointer is minted +when a conversation sends, and home has none yet. While the walk is still running the +column reads `looking…`; with no match it reads `no file matches`. + ## What does pressing space twice do — space space does nothing now Two spaces are ordinary text. The old Home shortcut is removed. Use `esc` to back out diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index db90cb20fe..71950c302b 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -1357,9 +1357,11 @@ There are three ways in. file in Finder or your file manager and press `cmd+v` / `ctrl+shift+v` — and codeaf attaches it. See "Dragging or pasting a screenshot in" below, which is the way most people do this. -2. **`/image `.** `~` becomes your home directory, a relative path is resolved - against the conversation's directory — or against **your own machine's** working - directory over `--host` — and an absolute path is left alone. +2. **`/attach `.** A picture handed to it is a picture. `~` becomes your home + directory, a relative path is resolved against the conversation's directory — or + against **your own machine's** working directory over `--host` — an absolute path is + left alone, and a quoted or backslash-escaped path is read as the one path it is. + (`/image ` was a second word for this until 2026-09-22 and is gone.) 3. **The `@` completion.** An image row in the list is tagged `img`. Choosing it **removes the half-typed `@token` from your sentence** and puts the file in the tray, instead of typing a path. @@ -1404,7 +1406,7 @@ because **the token goes to codeaf inside your message, in the position you left and the picture itself travels with it.** codeaf is told that `[image #1]` marks the first picture in the message, so the number you read is the picture it is looking at. -**A picture attached by `/image` or the `@` completion gets its token too**, appended +**A picture attached by `/attach` or the `@` completion gets its token too**, appended to the end of your sentence when you press `enter`, so "image 2" means the same thing whichever way the picture got there. @@ -1412,20 +1414,20 @@ whichever way the picture got there. complete terminal reading of it names real files **on this machine**. A sentence that mentions a `.png`, a diff, a stack trace, a log — all of it goes into the message box as the text it plainly is, which is what pasting has always done. -A paste over a line that starts with `/` is left as text too, so `/image ` and +A paste over a line that starts with `/` is left as text too, so `/attach ` and `/export ` still take a path. **Raw image data on the clipboard is not read.** Copying a picture out of a browser or a screenshot tool — as *pixels* rather than as a file — pastes nothing here. Save it to -a file first, then drag that in, or use `/image `. +a file first, then drag that in, or use `/attach `. ## What codeaf says when a picture is refused | Situation | Exact text | |---|---| -| `/image` with no path | `/image takes a path · try /image shot.png` | -| Not one of the five types | ` is not a picture · png, jpeg, webp and gif are` | -| Missing file, or a directory | `no such picture: ` | +| `/attach` with no path | opens the file browser rather than refusing | +| Not one of the five types | it is attached as a file, not refused | +| Missing file | `no such file: ` | | Already in the tray | ` is already attached` | | Dragged or pasted in over the ceiling | ` is over the 10MB image limit` | | Unreadable when you send | `could not read ` | @@ -1463,7 +1465,7 @@ can see, the message is refused before anything is sent and your pictures stay o tray. Once the message is sent, the transcript keeps the numbered marker and draws a compact control for each picture under your line. -A command with a full tray is still a command: `/image` adds a second picture rather +A command with a full tray is still a command: `/attach` adds a second picture rather than sending the first. ## Do I see my own screenshot in the conversation? @@ -1503,7 +1505,11 @@ of the message; that run must **begin** with `@`. So an `@` in the middle of a w an email address, a Go doc link — never opens the list. **What it walks:** the conversation's workspace, or **your own machine's** working -directory over `--host`. Skipped: `.git`, `vendor`, `node_modules`, every +directory over `--host`. **On home** the same list opens over home's box (since +2026-09-22) and walks the folder the next conversation opens in — the one on the rule — +so moving the target with `alt+p` or `/folder` walks again; it offers files and folders +there and never tasks, because a task pointer is minted when a conversation sends and +home has none yet. Skipped: `.git`, `vendor`, `node_modules`, every dot-directory, every dot-file, and every symlink. Unreadable directories are skipped rather than fatal. The walk is capped at **10,000 files**, and paths are stored relative to the root with forward slashes. @@ -1540,10 +1546,10 @@ snapshot already in memory and never touches the disk, so a slug pasted whole an sent in the same beat resolves to nothing and stays plain text. The entry remembered for `↑` is the sentence as you typed it, before expansion. -**The honest limit: `/image `, `/attach ` (and its `/upload ` alias), and `/export ` -get path completion.** That is the whole list. Any other command that takes a path gets -no completion at all, and says nothing about it. Over `--host`, completion still walks -the machine you are sitting at: `/attach` and `/image` send those local bytes across. +**The honest limit: `/attach ` (and its `/upload ` alias) and `/export ` get path +completion.** That is the whole list. Any other command that takes a path gets no +completion at all, and says nothing about it. Over `--host`, completion still walks the +machine you are sitting at: `/attach` sends those local bytes across. ## Keys in the command list and the `@` list @@ -1556,7 +1562,7 @@ follows what you type. Only these keys are taken from you: | `down` / `ctrl+n` | Move the list cursor down | | `esc` | Close the list. For the command list it also **seals that word** — the list does not reopen on the next letter of it. It does **not** interrupt a running turn | | `enter` | Command list: take the highlighted command. At the start of an otherwise empty box that **runs** it; anywhere else it replaces just that word with the command's name and runs nothing. If nothing matched, the line is sent as typed. `@` list: insert the highlighted task or file; if nothing is picked, the line is sent | -| `tab` | Read **before** the list. It only opens or commits an *argument* completion, over `/image ` or `/export `. With nothing to complete and an empty box it goes back to the last conversation | +| `tab` | Read **before** the list. It only opens or commits an *argument* completion, over `/attach ` or `/export `. With nothing to complete and an empty box it goes back to the last conversation | | `enter`, with an argument completion open | Closes the list and runs the line **as typed**. Your path is never swapped for the top-ranked row | ## Keys in the model picker and the sessions roster @@ -1795,7 +1801,7 @@ claim that `tab` is free. In order: a paste bracket makes it a literal tab; the eats it while it holds the keyboard (`esc` gives the keyboard back first); a box that has taken the whole keyboard on a place keeps it — the errand pane on home, the value being edited in settings; the rewind timeline and the inline rewind lift with it; and path -completion takes it over `/image ` or `/export `. Then, on a place, it is the next place. +completion takes it over `/attach ` or `/export `. Then, on a place, it is the next place. Only in a conversation, with none of those claiming it and the box empty, is it the way back. Two claims on `tab` were withdrawn when the places arrived, and both moved to a key that @@ -3273,7 +3279,7 @@ live-applies on the next render; work stays indented in either mode. ## Things this page does not cover -- **Slash commands** — what `/image`, `/export`, `/select`, `/copy`, `/model`, +- **Slash commands** — what `/attach`, `/export`, `/select`, `/copy`, `/model`, `/resume`, `/permissions` and the rest do: the commands page. - **The status line, the legend under the box, and the layout**: the screen page. - **Tasks, rooms, proposals and the roster**: the tasks pages. diff --git a/internal/manual/chat/running-on-another-machine.md b/internal/manual/chat/running-on-another-machine.md index 259a71e1c6..6cb4abf4f8 100644 --- a/internal/manual/chat/running-on-another-machine.md +++ b/internal/manual/chat/running-on-another-machine.md @@ -217,7 +217,7 @@ The **near** machine — the one you are sitting at — owns the surface: so what you typed while working on `devbox:code/app` belongs to that place - the model picker's cached list - the terminal itself -- **the paths for `/image`, `/attach` and `@` completion**, which are anchored here; a bare +- **the paths for `/attach` and `@` completion**, which are anchored here; a bare `/attach` opens the chooser on this machine - **the browser, the viewer and the file door** — the small `127.0.0.1` listener this window opens so that a path in a reply, `/files` and `/files ` can show you a file @@ -553,7 +553,7 @@ The task roster lists this far conversation's work. Its rows come from the far `room unavailable — this session has no task rooms`; the far task id opens its live room, and steering and stopping cross to that task's engine. -10. **`/image`, `/attach` and `@` are local, deliberately** — and this one is a capability as +10. **`/attach` and `@` are local, deliberately** — and this one is a capability as much as a limit. The picture or file is on the machine you are sitting at and its bytes travel with the message, so a relative path and the completion walk are anchored here rather than on the remote workspace. What you attach really does arrive over there; see @@ -741,10 +741,10 @@ own stream, so a turn whose words match a registered harness still asks you, and ## Attaching a picture or file, a bare /attach chooser, and @ paths, over --host -`/image`, `/attach` and `@` completion are **local on purpose**. The picture or file is on +`/attach` and `@` completion are **local on purpose**. The picture or file is on the machine you are sitting at, and its bytes travel with the message. -So a relative path you type after `/image` or `/attach`, and the `@` completion walk, are +So a relative path you type after `/attach`, and the `@` completion walk, are anchored **here** — to the directory you launched from — and not to the remote workspace. A bare `/attach` opens the add context chooser here too, already browsing the machine you are sitting at. Files chosen there reach the tray and travel with the next message. diff --git a/internal/manual/chat/what-i-can-do.md b/internal/manual/chat/what-i-can-do.md index 50a80a8f91..96293c690e 100644 --- a/internal/manual/chat/what-i-can-do.md +++ b/internal/manual/chat/what-i-can-do.md @@ -697,7 +697,7 @@ some cannot see at all. You attach pictures to a message you type. **Drag a file onto the terminal, or paste one you copied as a file**, and it is attached — your sentence gets a short `[image #1]` token where the path would have gone, and you can then talk about -"image #1" and be understood. `/image ` and the `@` completion attach one +"image #1" and be understood. `/attach ` and the `@` completion attach one too. The picture travels **inside the message as the picture**, not as a path somebody has to go and open. The "what the keys do" page has the whole of it. diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index a51b923863..f1e3440924 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -2373,6 +2373,8 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { {"stop showing tips", "hints-and-tips"}, {"what is a news line", "hints-and-tips"}, {"what is the dim sentence above the rule on home", "hints-and-tips"}, + {"does the @ file list work on home", "home"}, + {"is there an /image command", "attaching-files"}, {"the tip on home changed by itself", "hints-and-tips"}, {"every hint codeaf can show", "hints-and-tips"}, {"is a retired tip gone for good", "hints-and-tips"}, diff --git a/internal/tui3/app.go b/internal/tui3/app.go index cd365c138c..bab0a43d74 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -3511,6 +3511,10 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { return a, a.paste(text) case filesLoadedMsg: + if msg.home { + a.homeFilesLoaded(msg.paths) + return a, nil + } a.comp.all, a.comp.loaded, a.comp.loading = msg.paths, true, false a.comp.rank() a.touch() @@ -7007,17 +7011,11 @@ func (a *app) slash(line string) tea.Cmd { // (landcmd.go), and the landing itself runs off the loop. return a.runLandCommand(rest) - case "image": - // The other door onto the tray, for a picture that is not under this - // directory or not in the walk: a path, attached (attach.go). - a.attachPath(rest) - return nil - case "attach": - // The same tray, for everything that is not a picture: a log, a CSV, a - // stack trace saved to a file. The model is handed the PATH rather than - // the contents, because an attached file is a file and the session - // already has a `read` tool (attach.go). + // THE tray, for anything: a log, a CSV, a stack trace saved to a file — + // and a picture, which the tray tells apart by its name (attach.go). A + // file is handed to the model as a PATH rather than its contents, because + // the session already has a `read` tool; a picture travels as the picture. // // AND WITH NO PATH AFTER IT, THE BROWSER — the same sheet /folder opens, // with file intent (folderplace.go's [app.openContextPick]). It used to @@ -7239,6 +7237,7 @@ func (a *app) slash(line string) tea.Cmd { return a.runCacheCommand(rest) case "manual": + a.noticeEvent(eventManualAsked) // codeaf's own manual, in the conversation, AS WRITTEN (manualcmd.go). // It is an answer rather than a place for /status' reason — a person who // asked a question about the product wants it where they can scroll back diff --git a/internal/tui3/attach.go b/internal/tui3/attach.go index a9ab1976db..814c288e03 100644 --- a/internal/tui3/attach.go +++ b/internal/tui3/attach.go @@ -272,35 +272,15 @@ func (a *app) removeChip(i int) { a.touch() } -// attachPath is the /image command: one path, attached, or one note saying why -// not. Every refusal names the file, because "not an image" about a path the -// person typed is a sentence they can act on and "could not attach" is not. -func (a *app) attachPath(raw string) { - a.noticeEvent(eventAttached) - raw = strings.TrimSpace(raw) - if raw == "" { - a.note("/image takes a path · try /image shot.png") - return - } - path := a.resolvePath(raw) - if !isImagePath(path) { - a.note(filepath.Base(path) + " is not a picture · png, jpeg, webp and gif are") - return - } - info, err := os.Stat(path) - if err != nil || info.IsDir() { - a.note("no such picture: " + raw) - return - } - if !a.attach(path) { - a.note(filepath.Base(path) + " is already attached") - } -} - -// attachFilePath is the /attach command: one path, put on the tray as a FILE, -// or one note saying why not. Every refusal names the file, for [app.attachPath]'s -// reason — "no such file" about a path the person typed is a sentence they can -// act on and "could not attach" is not. +// attachFilePath is the /attach command: one path, put on the tray, or one +// note saying why not. Every refusal names the file — "no such file" about a +// path the person typed is a sentence they can act on and "could not attach" +// is not. +// +// /image WAS THE OTHER WORD FOR THIS AND IS GONE (2026-09-22). It took only a +// picture and refused everything else, which made two commands out of one +// gesture; the owner ruled that one word puts a thing on the tray and the +// tray tells a picture from a file, which the paragraph below already did. // // A PICTURE HANDED TO /attach IS STILL A PICTURE. Somebody who has learned one // word for putting a thing into a message should not have to learn that this @@ -383,7 +363,7 @@ func (a *app) attachFilePath(raw string) { // far side. Joining "shot.png" onto the far machine's workspace would name a // path that exists on neither machine. func (a *app) resolvePath(path string) string { - path = strings.TrimSpace(path) + path = unquotePath(path) if path == "~" || strings.HasPrefix(path, "~/") { if home, err := os.UserHomeDir(); err == nil { path = filepath.Join(home, strings.TrimPrefix(strings.TrimPrefix(path, "~"), "/")) @@ -396,6 +376,22 @@ func (a *app) resolvePath(path string) string { return path } +// unquotePath is what a person typed after a command, read the way the shell +// they copied it from would read it: a path wrapped in matching quotes loses +// them, and a backslash before a space is the space. A macOS Finder copy and +// a terminal drop both arrive in one of those shapes, and until 2026-09-22 +// `/attach '/Users/me/Screenshot 2026-09-18 at 1.35.20 PM.png'` was answered +// with `no such file` about a file that was there. The paste reader already +// knows both shapes ([pastedWords]); a run that reads as ONE word is taken as +// that word, and anything else is left exactly as typed. +func unquotePath(path string) string { + path = strings.TrimSpace(path) + if words := pastedWords(path); len(words) == 1 && words[0] != "" { + return words[0] + } + return path +} + // ── paths a Windows terminal hands to WSL ────────────────────────────────── const ( diff --git a/internal/tui3/attach_test.go b/internal/tui3/attach_test.go index d327909743..285949cf94 100644 --- a/internal/tui3/attach_test.go +++ b/internal/tui3/attach_test.go @@ -416,10 +416,12 @@ func TestEnterSendsAPictureWithNoWords(t *testing.T) { } } -// /image IS THE OTHER DOOR: a path this directory's walk never offered. -func TestTheImageCommandAttachesAPath(t *testing.T) { +// /attach IS THE OTHER DOOR: a path this directory's walk never offered, and +// a picture handed to it is a picture (/image, the word that took only +// pictures, is gone). +func TestTheAttachCommandAttachesAPicturePath(t *testing.T) { a, _, dir := attachLab(t, map[string]int{"shot.png": 8, "notes.md": 8}) - typeLine(t, a, "/image shot.png") + typeLine(t, a, "/attach shot.png") if want := []string{"shot.png"}; !equalStrings(chipNames(a), want) { t.Fatalf("chips are %v, want %v", chipNames(a), want) } @@ -427,27 +429,24 @@ func TestTheImageCommandAttachesAPath(t *testing.T) { t.Fatalf("the chip holds %q, want it resolved against the workspace", got) } - typeLine(t, a, "/image notes.md") - if len(a.chips) != 1 { - t.Fatalf("a markdown file was attached: %v", chipNames(a)) - } - if body := strings.Join(plainRows(a), "\n"); !strings.Contains(body, "not a picture") { - t.Fatalf("nothing said why:\n%s", body) + typeLine(t, a, "/attach notes.md") + if len(a.chips) != 2 || a.chips[1].name() != "notes.md" { + t.Fatalf("a markdown file did not join the tray as a file: %v", chipNames(a)) } - typeLine(t, a, "/image missing.png") - if body := strings.Join(plainRows(a), "\n"); !strings.Contains(body, "no such picture") { + typeLine(t, a, "/attach missing.png") + if body := strings.Join(plainRows(a), "\n"); !strings.Contains(body, "no such file") { t.Fatalf("a path that is not there said nothing:\n%s", body) } } // TAB COMPLETES THE COMMAND'S PATH, and enter belongs to the line under it: a // path typed out in full must not be swapped for whatever the list ranked first. -func TestTabCompletesTheImageCommandsPath(t *testing.T) { +func TestTabCompletesTheAttachCommandsPath(t *testing.T) { a, _, dir := attachLab(t, map[string]int{"pictures/shot.png": 8}) // An argument with nothing typed after it does not open a list of its own // accord — six hundred rows over an empty query is a list nobody asked for. - typeText(t, a, "/image ") + typeText(t, a, "/attach ") if a.comp.open { t.Fatal("the path list opened over an empty argument") } @@ -457,7 +456,7 @@ func TestTabCompletesTheImageCommandsPath(t *testing.T) { } typeText(t, a, "pictures/sh") drive(t, a, tab()) - if got, want := a.input.String(), "/image pictures/shot.png"; got != want { + if got, want := a.input.String(), "/attach pictures/shot.png"; got != want { t.Fatalf("the draft is %q, want %q", got, want) } if a.comp.open { diff --git a/internal/tui3/attachfile_test.go b/internal/tui3/attachfile_test.go index d7046da002..94d59dc3d7 100644 --- a/internal/tui3/attachfile_test.go +++ b/internal/tui3/attachfile_test.go @@ -134,7 +134,7 @@ func TestARemoteAttachmentTravelsAsBytesAndNamesNoLocalPath(t *testing.T) { func TestAPictureAndAFileRideOneMessage(t *testing.T) { a, agent, _ := fileLab(t, "devbox", map[string]int{"a.log": 8, "shot.png": 8, "b.csv": 8}) a.attachFilePath("a.log") - a.attachPath("shot.png") + a.attachFilePath("shot.png") a.attachFilePath("b.csv") labels := chipLabels(a.chips, a.pal) @@ -256,8 +256,8 @@ func TestAPictureHandedToAttachGoesOnAsAPicture(t *testing.T) { func TestRemovingAFileLeavesThePictureNumbersAlone(t *testing.T) { a, _, _ := fileLab(t, "", map[string]int{"a.log": 8, "one.png": 8, "two.png": 8}) a.attachFilePath("a.log") - a.attachPath("one.png") - a.attachPath("two.png") + a.attachFilePath("one.png") + a.attachFilePath("two.png") typeText(t, a, "compare [image #1] and [image #2]") a.removeChip(0) // the file diff --git a/internal/tui3/commands.go b/internal/tui3/commands.go index ea9bd8aea6..7bbde93ce0 100644 --- a/internal/tui3/commands.go +++ b/internal/tui3/commands.go @@ -68,7 +68,6 @@ var commands = []command{ // not where they learn its grammar; the manual's model page has the four // forms in a table ([modelArg] at the foot of this file). {name: "model", args: "", desc: "switch the model for the conversation or open task"}, - {name: "image", args: "", desc: "attach a picture · tab completes the path"}, // /set and /config were already answered by the dispatch before aliases // existed, and /connections and /sessions with them. They are written here // now because the table is the one place: a word the surface accepts and the diff --git a/internal/tui3/export_test.go b/internal/tui3/export_test.go index d7231b643a..9b1ff68932 100644 --- a/internal/tui3/export_test.go +++ b/internal/tui3/export_test.go @@ -314,7 +314,7 @@ func TestTheArgumentTokenAnswersEveryPathCommand(t *testing.T) { query string ok bool }{ - {"/image shot.png", 7, "shot.png", true}, + {"/attach shot.png", 8, "shot.png", true}, {"/export notes/today.md", 8, "notes/today.md", true}, {"/EXPORT notes.md", 8, "notes.md", true}, {"/export ", 8, "", true}, diff --git a/internal/tui3/files.go b/internal/tui3/files.go index 7323591387..b8041935bc 100644 --- a/internal/tui3/files.go +++ b/internal/tui3/files.go @@ -213,7 +213,7 @@ func (c *completion) close() { c.open = false } // the only one there was. Every command written here gets the completion; a // command that takes a path and is not written here gets nothing, silently, // which is the one failure worth watching for. -var argPrefixes = []string{"/image ", "/export ", "/attach "} +var argPrefixes = []string{"/export ", "/attach "} // argToken finds the path argument the caret is standing in: everything after // the command's prefix up to the caret. A path may hold spaces, so the token @@ -534,7 +534,11 @@ func (c *completion) rows(width, n int, pal palette, hover int) []string { } // filesLoadedMsg carries the walk back to the loop. -type filesLoadedMsg struct{ paths []string } +type filesLoadedMsg struct { + paths []string + // home says the walk was home's list's (homeat.go) and not the box's. + home bool +} // loadFiles walks the workspace off the loop. It runs ONCE per surface: the // list is a completion aid, and a person who creates a file mid-conversation diff --git a/internal/tui3/folderplace.go b/internal/tui3/folderplace.go index 73d892361e..3aa9d6ba6d 100644 --- a/internal/tui3/folderplace.go +++ b/internal/tui3/folderplace.go @@ -239,7 +239,20 @@ func (a *app) openFolderPick(query string) tea.Cmd { // [folderRemoteWord]'s argument said about the target: the pin would name a // directory the next conversation cannot open. func (a *app) openTargetFolderPick(query string) tea.Cmd { - a.noticeEvent(eventFolderPicked) + return a.openTargetContextPick(query, false) +} + +// openTargetContextPick is the sheet home opens, with either intent: a bare +// /attach wants a FILE for the tray and a bare /folder wants the next +// conversation's folder, and both are one sheet whose confirm already does +// both (folderact.go's [app.targetFolderConfirm]). The intent decides only +// which tip the gesture retires (notice.go). +func (a *app) openTargetContextPick(query string, files bool) tea.Cmd { + if files { + a.noticeEvent(eventAttached) + } else { + a.noticeEvent(eventFolderPicked) + } if a.hosted() { a.home.say(folderRemoteWord, "") return nil diff --git a/internal/tui3/head_test.go b/internal/tui3/head_test.go index 9c339f4b38..4b2fc1c8be 100644 --- a/internal/tui3/head_test.go +++ b/internal/tui3/head_test.go @@ -188,7 +188,11 @@ func TestWalkingBetweenAChatAndThePlacesMovesNothingAtTheFoot(t *testing.T) { } a.touch() rows := strings.Split(plain(frame(a)), "\n") - if got := footOf(rows); got != want || strings.TrimSpace(rows[want.rule-1]) != "" { + // THE CLEARANCE OVER HOME'S RULE IS THE TIP ROW since 2026-09-22 + // (hometip.go): the same row, so the foot stands where it stood, and + // it is blank everywhere else. + clear := strings.TrimSpace(rows[want.rule-1]) == "" || (to == pageHome && a.tipRow == want.rule-1) + if got := footOf(rows); got != want || !clear { 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)-placeFootRowsAt(size.h)-1:], "\n")) } diff --git a/internal/tui3/home.go b/internal/tui3/home.go index 6aa072f094..4e1da690c3 100644 --- a/internal/tui3/home.go +++ b/internal/tui3/home.go @@ -517,6 +517,10 @@ type homeLine struct { // rewritten, so a row can hold it without the staleness a world index would // carry ([homeLine.row] states that law). cmd *command + // comp is the row of home's `@` list a [homeCompletion] line offers — an + // index into [homeView.comp]'s lines, which are rebuilt with this list + // (homeat.go). + comp int } // homeBare is one project home knows only through the things keeping an eye on @@ -620,6 +624,11 @@ type homeView struct { // of the list mid-sentence has said what they meant, and the list reopening // under the rewritten token would be the surface asking again. cmd menu + // comp is the `@` list over this box — the same [completion] every + // conversation's box has, bound here to home's (homeat.go) — and walked is + // the folder its files were walked from, so a target that moves walks again. + comp completion + walked string // expanded is the projects somebody opened by hand, by bucket directory. // It outlives a rescan and a query, because folding is a thing a person did // and not a thing the data said. @@ -1463,6 +1472,13 @@ func (h *homeView) build() { h.picked = len(h.lines) > 0 return } + if h.comp.open { + // The `@` list keeps the completion's own cursor, which rank() moves + // with the query (homeat.go). + h.cursor = h.clamp(h.comp.cursor) + h.picked = len(h.lines) > 0 + return + } if h.searching() { // Keep an explicitly chosen result through filtering and idle refreshes. h.picked = h.picked && hadLine && h.pointSame(previousLine) @@ -1604,9 +1620,16 @@ func (h *homeView) dropUp() bool { return h.searching() } func (h *homeView) buildWorld() { commandRows := h.commandLines() if h.cmd.open { + h.comp.close() h.lines = append(h.lines, commandRows...) return } + // AND THE `@` LIST IS THE OTHER TYPED LIST, asked after the command list + // because at most one is open (homeat.go). + if rows := h.completionLines(); h.comp.open { + h.lines = append(h.lines, rows...) + return + } query := h.query() var found []homeHit for _, project := range h.world.Projects { @@ -1954,6 +1977,8 @@ func (l homeLine) sameRow(other homeLine) bool { return l.project != "" && l.project == other.project case homeCommand: return l.cmd != nil && l.cmd == other.cmd + case homeCompletion: + return l.comp == other.comp // the switcher's and the phone's own rows (place_home.go, homephone.go), // and spend's readouts, which are told apart the same way though the // cursor never rests on one (homepanel_spend.go). @@ -2248,7 +2273,7 @@ func (l homeLine) stop() bool { return true // the router's lane: an offered place is a door like every other door on this // column (homeplaces.go), and an offered command is one too (homeslash.go). - case homePlace, homeCommand: + case homePlace, homeCommand, homeCompletion: return true // phone lane: the inbox's own two stops (homephone.go). case homePhoneNews, homePhoneMore: @@ -2486,6 +2511,10 @@ func (a *app) homeKey(msg tea.KeyPressMsg) tea.Cmd { h.cmd.dismiss(h.cmd.at) h.build() } + if h.comp.open { + h.dismissCompletion() + h.build() + } return nil // THE FOUR KEYS THAT MOVE THE CURSOR ASK FOR NOTHING HERE. What the card @@ -3040,6 +3069,9 @@ func (a *app) homeEnter() tea.Cmd { // says — run it bare, or hold the box for the words it takes // (homeslash.go's [app.homeRunCommand]). return a.homeRunCommand(line) + case homeCompletion: + // ENTER PUTS THE PATH IN, or a picture on the tray (homeat.go). + return a.homeCompleteFile(line) case homeExchangeRow: // ENTER ON AN ERRAND HANDS IT THE KEYBOARD. There is nothing to open — // the exchange is already drawn beside the row, or on a narrow frame is @@ -4331,6 +4363,10 @@ func (a *app) homeList(width, room int, pal palette) []homeDrawn { switch { case h.cmd.open: word = commandNoMatchWord + case h.comp.open && !h.comp.loaded: + word = homeLookingWord + case h.comp.open: + word = homeNoFileWord case h.searching(): word = homeNoMatchWord case !h.known: @@ -4464,6 +4500,9 @@ func (a *app) homeLine(line homeLine, at, width int, pal palette) string { case homePlace: // A PLACE, OFFERED BECAUSE THE WORDS MATCH ITS NAME (homeplaces.go). return a.homePlaceRow(line, at, width, pal) + case homeCompletion: + // A PATH, OFFERED BECAUSE THE WORDS AFTER `@` MATCH IT (homeat.go). + return a.homeCompletionRow(line, at, width, pal) case homeCommand: // A COMMAND, OFFERED BECAUSE THE WORDS MATCH ITS NAME OR AN ALIAS // (homeslash.go). @@ -5213,6 +5252,9 @@ func (a *app) homeHintWords() string { if a.home.cmd.open { return "↑↓ pick · enter use it · esc back" } + if a.home.comp.open { + return homeCompletionHint + } if ex := a.paneExchange(); ex != nil { if ex.focused { return exchangeHint(ex) diff --git a/internal/tui3/homeat.go b/internal/tui3/homeat.go new file mode 100644 index 0000000000..1e0e9679b6 --- /dev/null +++ b/internal/tui3/homeat.go @@ -0,0 +1,173 @@ +package tui3 + +import ( + "path/filepath" + + tea "charm.land/bubbletea/v2" +) + +// ── THE `@` LIST ON HOME ───────────────────────────────────────────────────── +// +// Home's box is a draft for a conversation that does not exist yet, and until +// 2026-09-22 an `@` typed into it was two letters of a search: the completion +// that every conversation's box has (files.go) was bound to that box alone. The +// owner met it as a bug — the hint on home's own row promised the list — and +// this file is the other binding: the same [completion], synced against home's +// box, drawn as rows of home's column exactly as the slash list is +// (homeslash.go's [homeView.commandLines]), and answered by the same enter. +// +// WHAT IT WALKS IS WHERE THE NEXT CONVERSATION OPENS ([app.targetWhere]), +// because that is the folder the sentence is about; a target moved by `alt+p` +// or `/folder` walks again. It offers files and folders and never tasks: a task +// pointer is minted when a conversation sends (taskmention.go), and home has no +// conversation to mint it in yet. +// +// A PICTURE CHOSEN HERE GOES ON HOME'S TRAY, the way one chosen in a +// conversation goes on that conversation's ([app.completeFile]): the half-typed +// token comes out of the sentence and the chip rides into the conversation that +// opens next ([homeView.carrying]). + +// homeCompletion is one row of that list. It is numbered outside the +// [homeRowKind] iota block for [homeCommand]'s reason. +const homeCompletion homeRowKind = 251 + +// homeCompletionHint is the foot while the list is up: the three keys it takes. +const homeCompletionHint = "↑↓ pick · enter put it in · esc back" + +// The list's two empty states, in the conversation list's own words +// ([completion.rows]): the walk still running, and a query nothing matches. +const ( + homeLookingWord = "looking…" + homeNoFileWord = "no file matches" +) + +// completionLines syncs the list against the box and hands back its file rows, +// or nothing while the box holds no `@` token. It is asked after the command +// list, which wins when both could be open (app.go's [app.syncLists] states the +// same law for the conversation's box). +func (h *homeView) completionLines() []homeLine { + h.comp.sync(&h.box) + if !h.comp.open { + return nil + } + lines := make([]homeLine, 0, len(h.comp.lines)) + for i, line := range h.comp.lines { + if line.header != "" || line.file < 0 { + continue + } + lines = append(lines, homeLine{kind: homeCompletion, comp: i}) + } + return lines +} + +// homeCompletionRow paints one offered path: the path, and in the margin what +// the row is — `folder`, or `img` for a picture that choosing will attach. +func (a *app) homeCompletionRow(line homeLine, at, width int, pal palette) string { + h := &a.home + path, note, ok := h.completionWords(line) + if !ok { + return "" + } + return overlayRow(path, note, at == h.cursor, false, at == h.hover && at == h.cursor, width, pal) +} + +// completionWords is the path a completion row offers and its tag, and false +// for a row that no longer points into the list. +func (h *homeView) completionWords(line homeLine) (path, note string, ok bool) { + c := &h.comp + if line.comp < 0 || line.comp >= len(c.lines) || c.lines[line.comp].file < 0 { + return "", "", false + } + return c.all[c.lines[line.comp].file], c.lineNote(line.comp), true +} + +// loadHomeFiles walks the target folder for the list, once per target: a +// walk already done or already running is left alone, and a target that moved +// since the last walk starts a fresh one. It is asked after every key on home +// (place_home.go), and answers nil on every key that did not open the list. +func (a *app) loadHomeFiles() tea.Cmd { + h := &a.home + if !h.comp.open { + return nil + } + root := a.targetWhere() + if root == "" { + root = a.pathRoot() + } + if root == "" { + return nil + } + if h.walked != root { + h.comp.all, h.comp.loaded, h.comp.loading = nil, false, false + h.walked = root + } + if h.comp.loaded || h.comp.loading { + return nil + } + // Tasks never load here (the file's own note), so the list is never + // waiting on them. + h.comp.tasksLoaded, h.comp.loading = true, true + return func() tea.Msg { return filesLoadedMsg{paths: walkFiles(root, walkCap), home: true} } +} + +// homeFilesLoaded takes the walk back onto home's list and rebuilds the rows +// under the cursor. +func (a *app) homeFilesLoaded(paths []string) { + h := &a.home + h.comp.all, h.comp.loaded, h.comp.loading = paths, true, false + h.comp.rank() + h.build() + a.touch() +} + +// homeCompleteFile is enter on a row of the list, and it is [app.completeFile] +// said for home's box: the path goes into the sentence after the `@`, or a +// picture comes out of the sentence and onto the tray. +func (a *app) homeCompleteFile(line homeLine) tea.Cmd { + h := &a.home + c := &h.comp + path, _, ok := h.completionWords(line) + if !ok { + c.close() + h.build() + return nil + } + e := &h.box + if isImagePath(path) { + head := append([]rune(nil), e.value[:c.at]...) + tail := append([]rune(nil), e.value[e.cursor:]...) + e.value = append(head, tail...) + e.cursor = c.at + full := path + if !filepath.IsAbs(full) { + full = filepath.Join(h.walked, path) + } + if a.attach(full) { + h.say(folderAttachedWord+filepath.Base(path)+homeRidesWord, "") + } + h.carrying = len(a.chips) > 0 + c.done = "" + c.close() + h.build() + a.touch() + return nil + } + head := append([]rune(nil), e.value[:c.at+1]...) + tail := append([]rune(nil), e.value[e.cursor:]...) + e.value = append(append(head, []rune(path)...), tail...) + e.cursor = c.at + 1 + len([]rune(path)) + c.done = path + c.close() + h.build() + a.touch() + return nil +} + +// dismissCompletion is esc over the list: it closes, and stays closed over +// exactly this query — the next letter of the token opens it again, which is +// the conversation list's own rule (app.go's [app.dismissLists] seals only the +// command list). [completion.done] is what holds it shut meanwhile. +func (h *homeView) dismissCompletion() { + h.comp.done = h.comp.query + h.comp.close() +} diff --git a/internal/tui3/homeat_test.go b/internal/tui3/homeat_test.go new file mode 100644 index 0000000000..5f0288781b --- /dev/null +++ b/internal/tui3/homeat_test.go @@ -0,0 +1,171 @@ +package tui3 + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// ── THE `@` LIST ON HOME, AND A PATH THE WAY A SHELL WOULD READ IT ─────────── + +// atHome is home open over a target folder holding a note and a picture. +func atHome(t *testing.T) (*app, string) { + t.Helper() + lab := newHomeLab(t) + a := lab.door("") + root := t.TempDir() + for _, name := range []string{"notes.md", "shot.png"} { + if err := os.WriteFile(filepath.Join(root, name), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + } + a.target.where = root + a.showPage(pageHome) + return a, root +} + +// Typing `@` and a letter into home's box opens the list over the target +// folder, enter puts the path into the sentence, and the list does not reopen +// over its own answer. +func TestAtOpensTheFileListOnHomeAndEnterPutsThePathIn(t *testing.T) { + a, root := atHome(t) + drive(t, a, key("@"), key("n")) + h := &a.home + if !h.comp.open { + t.Fatal("typing @n on home did not open the list") + } + if h.walked != root { + t.Fatalf("the list walked %q, want the target %q", h.walked, root) + } + if !h.comp.loaded { + t.Fatal("the walk did not land") + } + text := homeText(a) + if !strings.Contains(text, "notes.md") { + t.Fatalf("home does not offer the note:\n%s", text) + } + if !strings.Contains(a.homeHint(), "enter put it in") { + t.Fatalf("the foot does not name the list's keys: %q", a.homeHint()) + } + if a.noticeHomeHint() != "" { + t.Fatal("a tip drew under the @ list") + } + line, ok := h.focusedLine() + if !ok || line.kind != homeCompletion { + t.Fatalf("the cursor is not on a completion row: %+v", line) + } + drive(t, a, key("enter")) + if got := h.box.String(); got != "@notes.md" { + t.Fatalf("enter left the box as %q, want @notes.md", got) + } + if h.comp.open { + t.Fatal("the list stayed open on top of its own answer") + } + if !a.at(pageHome) { + t.Fatal("completing a path left home") + } +} + +// Choosing a picture takes the token out of the sentence and puts the picture +// on home's tray, which rides into the next conversation. +func TestAPictureChosenFromTheListGoesOnHomesTray(t *testing.T) { + a, root := atHome(t) + drive(t, a, key("@"), key("s"), key("h")) + h := &a.home + if !h.comp.open { + t.Fatal("typing @sh on home did not open the list") + } + drive(t, a, key("enter")) + if got := h.box.String(); got != "" { + t.Fatalf("the token was left in the box: %q", got) + } + if len(a.chips) != 1 || a.chips[0].path != filepath.Join(root, "shot.png") { + t.Fatalf("the picture did not reach the tray: %+v", a.chips) + } + if !h.carrying { + t.Fatal("home is not carrying the tray") + } + if want := folderAttachedWord + "shot.png" + homeRidesWord; h.msg != want { + t.Fatalf("home said %q, want %q", h.msg, want) + } +} + +// esc closes the list and leaves the word alone; the next letter of the token +// opens it again, which is the conversation list's own rule (app.go's +// [app.dismissLists] seals only the command list). +func TestEscClosesTheAtListOnHomeAndLeavesTheWord(t *testing.T) { + a, _ := atHome(t) + drive(t, a, key("@"), key("n")) + h := &a.home + drive(t, a, key("esc")) + if h.comp.open { + t.Fatal("esc did not close the list") + } + if got := h.box.String(); got != "@n" { + t.Fatalf("esc changed the draft to %q", got) + } + drive(t, a, key("o")) + if !h.comp.open { + t.Fatal("the next letter of the token did not open the list again") + } +} + +// The command list wins over the file list, so `/` never draws both. +func TestTheCommandListWinsOverTheFileListOnHome(t *testing.T) { + a, _ := atHome(t) + drive(t, a, key("/"), key("m")) + h := &a.home + if !h.cmd.open || h.comp.open { + t.Fatalf("over a slash word cmd=%v comp=%v, want the command list alone", h.cmd.open, h.comp.open) + } +} + +// A path in quotes, or with its spaces backslashed, is the one path it is — +// the shape Finder and a terminal drop hand you. +func TestAQuotedOrEscapedPathIsReadAsOnePath(t *testing.T) { + a, _ := sheetApp(t) + want := "/Users/me/Screenshot 2026-09-18 at 1.35.20 PM.png" + for _, typed := range []string{ + "'" + want + "'", + `"` + want + `"`, + strings.ReplaceAll(want, " ", `\ `), + " '" + want + "' ", + } { + if got := a.resolvePath(typed); got != want { + t.Errorf("resolvePath(%q) = %q, want %q", typed, got, want) + } + } + // A path with raw spaces and no quotes is left exactly as typed. + if got := a.resolvePath(want); got != want { + t.Errorf("a raw path was changed: %q", got) + } + // And a quoted picture reaches the tray as a picture. + dir := t.TempDir() + shot := filepath.Join(dir, "Screen Shot.png") + if err := os.WriteFile(shot, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + a.attachFilePath("'" + shot + "'") + if len(a.chips) != 1 || a.chips[0].path != shot || !isImagePath(a.chips[0].path) { + t.Fatalf("a quoted picture did not reach the tray as a picture: %+v", a.chips) + } +} + +// /image is gone: the table does not offer it, and typing it is an unknown +// word that attaches nothing. +func TestThereIsNoImageCommand(t *testing.T) { + for _, c := range commands { + if c.name == "image" { + t.Fatal("the command table still offers /image") + } + } + a, _ := sheetApp(t) + a.slash("/image shot.png") + if len(a.chips) != 0 { + t.Fatalf("/image still attached something: %+v", a.chips) + } + if body := strings.Join(plainRows(a), "\n"); !strings.Contains(body, "no command called /image") { + t.Fatalf("/image was not answered as an unknown word:\n%s", body) + } +} diff --git a/internal/tui3/homefate_test.go b/internal/tui3/homefate_test.go index 64f97651c8..b6614fe999 100644 --- a/internal/tui3/homefate_test.go +++ b/internal/tui3/homefate_test.go @@ -199,9 +199,9 @@ func TestAttachAtHomeLandsOnHomesTrayAndSaysSo(t *testing.T) { } // A picture goes the same way, through the same tray. - runCmd(a.homeSlash("/image " + filepath.Join(root, "here", "shot.png"))) + runCmd(a.homeSlash("/attach " + filepath.Join(root, "here", "shot.png"))) if !a.at(pageHome) { - t.Fatal("/image at home opened a conversation") + t.Fatal("/attach at home opened a conversation") } if len(a.chips) != 2 || a.chips[1].name() != "shot.png" { t.Fatalf("the picture did not reach home's tray: %+v", a.chips) @@ -211,22 +211,21 @@ func TestAttachAtHomeLandsOnHomesTrayAndSaysSo(t *testing.T) { } } -// A BARE /attach ASKS FOR THE PATH WHERE IT WAS TYPED. It used to open a -// conversation to hold a browser, which is a conversation started for a -// question — and the two ways a file reaches home's tray are named instead. -func TestBareAttachAtHomeAsksForThePath(t *testing.T) { +// A BARE /attach AT HOME OPENS THE BROWSER, aimed at the next conversation's +// folder the way a bare /folder is, and a file chosen there lands on home's +// tray (folderact.go's [app.targetFolderConfirm]). It used to answer `type +// the path after /attach`, a correction where a person wanted a door. +func TestBareAttachAtHomeOpensTheBrowserForTheTarget(t *testing.T) { a, _, _ := mixedLab(t) runCmd(a.openHome()) runCmd(a.homeSlash("/attach")) - if !a.at(pageHome) { - t.Fatal("a bare /attach at home left the screen") - } - if a.folder.open { - t.Fatal("a bare /attach at home opened the browser") + if !a.folder.open || !a.folder.forTarget { + t.Fatalf("a bare /attach at home did not open the target's browser: open=%v target=%v", + a.folder.open, a.folder.forTarget) } - if a.home.msg != homeTypeThePathWord { - t.Fatalf("home said %q, want %q", a.home.msg, homeTypeThePathWord) + if a.home.msg != "" { + t.Fatalf("a bare /attach at home said %q instead of opening the sheet", a.home.msg) } } diff --git a/internal/tui3/homephone.go b/internal/tui3/homephone.go index bcfe31712c..bb32143af5 100644 --- a/internal/tui3/homephone.go +++ b/internal/tui3/homephone.go @@ -686,6 +686,9 @@ func (a *app) homePhoneWords(line homeLine, pal palette) (string, string, noteIn homeNoteInk(line.row, a.homeHeld(line.row) || a.homeRowGone(line.row)) case homeCommand: return line.cmd.typed(), line.cmd.note(a.chords), nil + case homeCompletion: + path, note, _ := h.completionWords(line) + return path, note, nil case homeItem: return standGlyph(line.view.Item, line.view.Running, line.view.News, pal.ascii) + " " + strings.TrimSpace(line.view.Item.Words), diff --git a/internal/tui3/homeslash.go b/internal/tui3/homeslash.go index 55e98ef716..54dfbb6721 100644 --- a/internal/tui3/homeslash.go +++ b/internal/tui3/homeslash.go @@ -192,7 +192,7 @@ func homeFate(word, rest string) string { return fatePlace case "resume": return fateResume - case "attach", "image": + case "attach": return fateTray case "quit": return fateQuit @@ -255,10 +255,6 @@ const ( // than refusing, because the thing the person asked for is already in front // of them. homeIsTheResumeWord = "this list is /resume · enter opens a row" - // homeTypeThePathWord is a bare /attach. The browser is /folder's door and - // this command's own is a path, so the line says the two ways a file gets - // onto home's tray rather than opening a sheet nobody asked for. - homeTypeThePathWord = "type the path after /attach · or drop the file here" // homeRidesWord is the tail of the line a file attached at home leaves: the // tray belongs to the person and travels into the conversation home opens // next (home.go's [app.homeStart] carries it there). @@ -364,15 +360,11 @@ func (a *app) homeSlash(line string) tea.Cmd { // `/folder` makes, said in the same words. func (a *app) homeTrayCommand(word, rest string) tea.Cmd { if rest == "" { - if word == "image" { - // The dispatcher's own usage line, said where it was typed rather than - // in a conversation opened to hold it. - a.echoHome = true - defer func() { a.echoHome = false }() - return a.slash("/image") - } - a.home.say(homeTypeThePathWord, "") - return nil + // A BARE /attach IS THE BROWSER, aimed at the next conversation's folder + // the way a bare /folder is (folderplace.go's [app.openTargetContextPick]): + // a file chosen there lands on home's tray. It used to answer `type the + // path after /attach`, which is a correction rather than an answer. + return a.openTargetContextPick("", true) } // AND ONLY ON THIS MACHINE'S OWN DISK. Over a connection the directory this // process can stat is the laptop's and the next conversation is on the other diff --git a/internal/tui3/host_test.go b/internal/tui3/host_test.go index b6211610fd..9a52b7706e 100644 --- a/internal/tui3/host_test.go +++ b/internal/tui3/host_test.go @@ -311,7 +311,7 @@ func TestAPictureIsFoundOnTheMachineThePersonIsSittingAt(t *testing.T) { if got := a.resolvePath("shot.png"); got != path { t.Fatalf("resolvePath = %q, want the local file — not %q joined onto a path on another machine", got, "shot.png") } - a.attachPath("shot.png") + a.attachFilePath("shot.png") if len(a.chips) != 1 { t.Fatalf("the picture did not attach: %s", strings.Join(plainRows(a), "\n")) } diff --git a/internal/tui3/imagepaste_test.go b/internal/tui3/imagepaste_test.go index e910f4444b..656151a5bc 100644 --- a/internal/tui3/imagepaste_test.go +++ b/internal/tui3/imagepaste_test.go @@ -332,16 +332,16 @@ func TestAPasteThatIsNotAllPicturesStaysText(t *testing.T) { } } -// A SLASH COMMAND'S ARGUMENT IS A PATH AND MUST STAY ONE: /image is the one line -// on this surface whose whole job is to take one, and dropping a file on it is -// somebody using it exactly as documented. +// A SLASH COMMAND'S ARGUMENT IS A PATH AND MUST STAY ONE: /attach is the one +// line on this surface whose whole job is to take one, and dropping a file on +// it is somebody using it exactly as documented. func TestAPathDroppedOnASlashCommandStaysAPath(t *testing.T) { a, _, dir := attachLab(t, map[string]int{"Screen Shot.png": 12}) path := filepath.Join(dir, "Screen Shot.png") - typeText(t, a, "/image ") + typeText(t, a, "/attach ") pasteText(t, a, path) - if got := a.input.String(); got != "/image "+path { + if got := a.input.String(); got != "/attach "+path { t.Fatalf("the draft is %q, want the path left alone", got) } if len(a.chips) != 0 { diff --git a/internal/tui3/notice.go b/internal/tui3/notice.go index 7bdb769a3a..2c225403cf 100644 --- a/internal/tui3/notice.go +++ b/internal/tui3/notice.go @@ -141,9 +141,12 @@ const ( // eventTaskTyped is `/task ` reaching its command (taskcommand.go); // the task it starts fires [eventTaskStarted] on its own later. eventTaskTyped = "task-typed" - // eventSpelledOut is `ctrl+r` asking for the draft to be spelled out - // (spellout.go's [app.spellAsk]). - eventSpelledOut = "spelled-out" + // eventManualAsked is /manual reaching its command, bare or with a page or + // a question (app.go). + eventManualAsked = "manual-asked" + // eventTabReopened is ctrl+shift+t bringing a closed tab back + // (tabreopen.go). + eventTabReopened = "tab-reopened" // eventAtOpened is the `@` completion list coming up under the box // (app.go's [app.syncLists]). eventAtOpened = "at-opened" @@ -192,7 +195,7 @@ var noticeEvents = []string{ eventMenuOpened, eventRewound, eventCopyEntered, eventModelSwitched, eventCompacted, eventFilesOpened, eventResumeOpened, eventCostShown, eventStandingOpened, eventDeliverableMade, - eventAsked, eventTaskTyped, eventSpelledOut, eventAtOpened, eventAttached, + eventAsked, eventTaskTyped, eventManualAsked, eventTabReopened, eventAtOpened, eventAttached, eventFolderPicked, eventModelListOpened, eventCrewShown, eventBudgetShown, eventSpendOpened, eventSteered, eventQueued, eventChatStarted, eventPlaceJumped, eventRemembered, eventSearchOpened, eventSubharnessOpened, @@ -375,12 +378,6 @@ var notices = []notice{ text: "/ask answers right here without opening a conversation", retire: eventAsked, }, - { - id: "task-from-home", slot: slotHint, place: onHome, - armed: askable, - text: "alt+enter sends what you typed off as a task", - retire: eventAsked, - }, { id: "task-in-chat", slot: slotHint, place: inChat, priority: 55, armed: spoken, @@ -394,10 +391,16 @@ var notices = []notice{ retire: eventStandingOpened, }, { - id: "spell-out", slot: slotHint, place: everywhere, priority: 26, - armed: func(a *app) bool { _, ok := a.spellDoor(); return ok }, - text: "ctrl+r spells out what your sentence is taken to mean", - retire: eventSpelledOut, + id: "manual-answers", slot: slotHint, place: everywhere, priority: 26, + armed: ready, + text: "/manual answers any question about codeaf from its own manual", + retire: eventManualAsked, + }, + { + id: "reopen-tab", slot: slotHint, place: everywhere, priority: 17, + armed: ready, + text: "ctrl+shift+t reopens the tab you just closed", + retire: eventTabReopened, }, // ── files and context ─────────────────────────────────────────────────── { @@ -421,7 +424,7 @@ var notices = []notice{ { id: "attach-a-picture", slot: slotHint, place: everywhere, priority: 6, armed: ready, - text: "/image attaches a picture, or paste a screenshot in", + text: "/attach takes a picture too, or paste a screenshot in", retire: eventAttached, }, { @@ -942,7 +945,7 @@ func (a *app) noticeHomeHint() string { // noticeHomeQuiet is whether nothing on home outranks a tip: the box is at // rest, no list or layer has the keyboard, and no exchange is being read. func (a *app) noticeHomeQuiet() bool { - return a.at(pageHome) && a.home.box.empty() && !a.home.cmd.open && !a.home.searching() && + return a.at(pageHome) && a.home.box.empty() && !a.home.cmd.open && !a.home.comp.open && !a.home.searching() && a.paneExchange() == nil && !a.targetPickShowing() && !a.composer.open && !a.hopShowing() } diff --git a/internal/tui3/notice_test.go b/internal/tui3/notice_test.go index b724b73cd7..9fd356de5a 100644 --- a/internal/tui3/notice_test.go +++ b/internal/tui3/notice_test.go @@ -511,10 +511,8 @@ func TestEveryRetireEventIsProvedByItsGesture(t *testing.T) { eventDeliverableMade: func(t *testing.T, a *app) { a.exportDone(exportedMsg{path: "/tmp/lab/talk.md"}) }, eventAsked: func(t *testing.T, a *app) { a.askHere("what is this") }, eventTaskTyped: func(t *testing.T, a *app) { a.slash("/task") }, - eventSpelledOut: func(t *testing.T, a *app) { - a.input.setText("build me a login page") - a.spellAsk() - }, + eventManualAsked: func(t *testing.T, a *app) { a.slash("/manual") }, + eventTabReopened: func(t *testing.T, a *app) { drive(t, a, reopenPress()) }, eventAtOpened: func(t *testing.T, a *app) { drive(t, a, key("@"), key("s"), key("h")) if !a.comp.open { diff --git a/internal/tui3/place_home.go b/internal/tui3/place_home.go index d55b928265..cad9fa707a 100644 --- a/internal/tui3/place_home.go +++ b/internal/tui3/place_home.go @@ -527,7 +527,8 @@ func (placeHome) wheel(a *app, delta int) (tea.Cmd, bool) { return nil, false } // nothing, which is what it costs to never be stale. func (placeHome) key(a *app, msg tea.KeyPressMsg) tea.Cmd { answered := a.homeKey(msg) - return tea.Batch(answered, a.refreshHomeCard(a.now())) + // AND THE `@` LIST'S WALK STARTS THE KEY THAT OPENED IT (homeat.go). + return tea.Batch(answered, a.loadHomeFiles(), a.refreshHomeCard(a.now())) } // owns is the two layers of home that take the WHOLE keyboard, `tab` included, diff --git a/internal/tui3/spellout.go b/internal/tui3/spellout.go index 3df3950b2b..e067862791 100644 --- a/internal/tui3/spellout.go +++ b/internal/tui3/spellout.go @@ -313,9 +313,6 @@ func (a *app) spellKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { // hint slot turns the build's spinner while it is out, and [app.wake] is what // keeps the frames coming for it — nothing else on the surface is moving. func (a *app) spellAsk() tea.Cmd { - // The chord was reached for; the tip that names it is only ever armed where - // the door below stands (notice.go). - a.noticeEvent(eventSpelledOut) door, ok := a.spellDoor() if !ok { return nil diff --git a/internal/tui3/tabreopen.go b/internal/tui3/tabreopen.go index 23673c19eb..0f8480514d 100644 --- a/internal/tui3/tabreopen.go +++ b/internal/tui3/tabreopen.go @@ -68,6 +68,9 @@ func (a *app) reopenTabKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { if a.railHold { a.railTake(false) } + // The chord was reached for, whether or not there was a tab to bring back + // (notice.go). + a.noticeEvent(eventTabReopened) return a.reopenClosedTab(), true } From daa9308e422a146f02249cba0f0b0746cbcefda3 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Tue, 22 Sep 2026 09:47:34 -0400 Subject: [PATCH 05/39] chat: one tip list on both boxes, the conversation's tip row after a quiet minute, the project on both keys rows, /attach opens the browser from the list, search by name with memory off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tip table is one set now: every hint draws over home's box and a conversation's, in the table's order, round and round, and a tip that has just become true jumps the ring. The priority numbers, the per-row place and the conversation's turn gap are gone. A showing is a visible change of hands on either row, six of them retire a tip. A conversation says its tips the way home does — the row over the rule, right-aligned, bulb and cross — but only after a minute with no key pressed and no turn ending, on one self-sustaining clock started at Init; a key hides it again. The keys row no longer carries a tip. The project came off both seams to the right end of the keys row under the box, cut on the right where the keys leave it no room, and it is the folder door there in a conversation as it is on home. Enter on /attach in the command list opens the browser at once, the way /folder does: the bare row is its own row, the row stays for a typed path. A path with a raw apostrophe in its name is no longer read as a shell quote. With memory off the search place matches conversations by name and project, the way home's box does, and says so, instead of refusing. The picture-and-media tip gave its seat to ctrl+b copy mode. The manual follows on every point, with four new probes. Co-Authored-By: Claude Fable 5.1 --- internal/manual/chat/commands.md | 5 +- internal/manual/chat/hints-and-tips.md | 228 ++++----- internal/manual/chat/home.md | 18 +- internal/manual/chat/places.md | 35 +- internal/manual/chat/screen.md | 36 +- internal/manual/chat_test.go | 4 + internal/tui3/app.go | 38 +- internal/tui3/attach.go | 8 + internal/tui3/boxseam_test.go | 45 +- internal/tui3/bundle_test.go | 35 +- internal/tui3/chattip_test.go | 353 +++++++++++++ internal/tui3/commands.go | 9 +- internal/tui3/foot.go | 2 +- internal/tui3/footswap.go | 27 +- internal/tui3/helpreach_test.go | 6 +- internal/tui3/home.go | 2 +- internal/tui3/home_test.go | 4 +- internal/tui3/homefate_test.go | 2 +- internal/tui3/homeslash_test.go | 2 +- internal/tui3/hometip.go | 69 +-- internal/tui3/hometip_test.go | 107 ++-- internal/tui3/hometiplayout_test.go | 4 +- internal/tui3/host_test.go | 9 +- internal/tui3/hover.go | 9 +- internal/tui3/notice.go | 654 ++++++++++++++----------- internal/tui3/notice_ledger.go | 3 +- internal/tui3/notice_test.go | 200 ++++---- internal/tui3/pages.go | 4 +- internal/tui3/place_search.go | 22 +- internal/tui3/projectseam.go | 52 +- internal/tui3/projectseam_test.go | 35 +- internal/tui3/render.go | 20 +- internal/tui3/searchplace.go | 82 +++- internal/tui3/statusdeck_test.go | 2 +- internal/tui3/view.go | 21 + 35 files changed, 1429 insertions(+), 723 deletions(-) create mode 100644 internal/tui3/chattip_test.go diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index 7367319ef3..a28d465690 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -164,7 +164,7 @@ Canonical word, the other words it answers to, its argument form, and what it do | `/home` | — | — | every project and conversation on this machine, fullscreen | | `/folder` | `/place`, `/dir` | — | locally opens the add context sheet; over `--host` says the folder chooser is unavailable | | `/folder` | `/place`, `/dir` | `` | locally opens it with that in the box; over `--host` gives the same refusal | -| `/attach` | `/upload` | — | opens the add context sheet for files, including over `--host` | +| `/attach` | `/upload` | — | opens the add context sheet for files, including over `--host`; enter on this row of the `/` list opens it at once | | `/attach` | `/upload` | `` | a file goes on the tray; locally a folder is referred, while over `--host` it is refused | | `/land` | — | — | says what has been changed for a folder you chose and is waiting to go into it | | `/land` | — | `now` | …puts it in: a branch merged for a repository, files copied back for a plain folder | @@ -725,7 +725,8 @@ and the note's leading `· `; strip those before feeding it to a parser. `/search` opens the **search place** — everything that has been said on this machine, found by the words you remember of it. It is the same place `alt+7` opens and the same place `tab` walks to. It takes no argument: the place *is* a box, and typing in it -searches. +searches. With the **memory** row off nothing said is indexed, and the place matches +conversations by their name and project instead, saying so (see the *places* page). `/spend` opens the **spend place** — what this machine has cost, by the day, by the model and by what it was for. It is the same place `alt+3` opens. diff --git a/internal/manual/chat/hints-and-tips.md b/internal/manual/chat/hints-and-tips.md index 9115778b41..c43a260a98 100644 --- a/internal/manual/chat/hints-and-tips.md +++ b/internal/manual/chat/hints-and-tips.md @@ -1,47 +1,42 @@ # Hints and tips -## What was that tip above the message box — the one-line hint in the border - -The dim row under your message box — the last row of the frame — is the hint slot (until -2026-09-17 it was the right end of the rule above the box; the numbers have that end now). -Most of the time it names the keys that work right now — `ctrl+c interrupt` while an answer is coming, -`y allow · n deny · a always` while codeaf is asking you something, `/ commands` when nothing -is happening. Once you have used codeaf a little, that idle line sometimes carries a tip -instead: one sentence naming a key or a command you have not used yet, and what it does. -For example `ctrl+. sees every task this project has run`, or `/rewind takes back an earlier -message`. - -A tip only appears over an empty box while nothing else is happening. The moment you type, -open a list, or an answer starts, the slot goes back to the keys for that state; the tip -returns when things are quiet again. A tip never takes a row of its own and never blocks a -keystroke — it is the keys row, which is on the screen anyway. - -Home has a row of its own for the same tips, directly above the rule over its box — see -*The dim sentence above the rule on home*. +## What was that tip above the message box — the one-line hint over the rule, the sentence with a bulb + +The dim sentence directly above the rule over your message box, led by a bulb and closed +by a small cross — `💡 ctrl+. sees every task this project has run ✕` — is a **tip**: one +line naming a key or a command you have not used yet, and what it does. It reads the way +every hint on this surface does: the key or the command first, then what it does. Home has +the same row over its own box, and the two rows draw from **one list** of thirty tips (below). + +**In a conversation the row appears only once you have been quiet for a minute** — no key +pressed and no answer landing for sixty seconds — so it never talks over you while you type +or read what just arrived. The moment you press a key it goes away, and the minute starts +again. Left alone, the row moves on to the next tip every two minutes. On home the row is +there from the first minute, moves on every time you come to home and every two minutes at +rest, and goes blank while the box is being typed into or a list is up. + +**The small cross after the tip puts it away**: click it and the row is blank until it +next changes hands — the next visit to home, the row's next two-minute turn, a quiet minute +in a conversation. Putting a tip away does not retire it. A tip never takes a row of its +own: it stands on the blank row that separates the conversation (or home's list) from the +rule, and never blocks a keystroke. The keys row at the very foot — `alt+e effort · alt+a +approvals · / commands` — is not a tip and never changes; until 2026-09-22 the tip stood +there in a conversation, and it moved up to the row over the rule so both boxes say their +tips the same way. + +On a Mac the row says `opt` where the table below says `alt`, exactly as the keys row does. ## The dim sentence above the rule on home — the tip on home, what is that line over the box On home the tip is the dim row **directly above the rule** over the message box — the blank that separates the list from the rule, with one sentence written into its right end, led -by a bulb: `💡 /ask answers right here without opening a conversation ✕`. It reads the way -every tip does: the key or the command first, then what it does. **The small cross after it -puts the tip away** — click it and the row is blank until your next visit to home or the -row's own two-minute turn brings the next tip; putting a tip away does not retire it. The -keys row at the very foot of home is not a tip and never changes: it names the row's options -and the draft's chords (`→ options · alt+p project · alt+e effort · alt+a approvals · / -commands`) and ends with `project: `, where the next conversation opens. - -It is there only while home is at rest: the box empty, no command list or model list up, no -task or question open in the right pane. Type a letter and the row is blank again; clear -the box and the tip is back. The row is the same row whether or not a tip is on it, so the -list above never moves. - -**It changes on every visit and every two minutes.** Each time you come to home — `esc` -from a conversation, `/home`, `alt+1`, `tab` — the row moves on to the next tip that is -true for you, in a fixed order, round and round. Left at rest, it moves on by itself after -two minutes; a home nobody is looking at (the box being typed into, a list up) does not -age, because what has not been read has not been shown. On a Mac the row says `opt` where -the table below says `alt`, exactly as the keys row does. +by a bulb: `💡 /ask answers right here without opening a conversation ✕`. It is drawn only +while the box is empty and nothing else is up — a letter in the box, the `/` list, the `@` +list or a reply being read all take the row back — and it moves on to the next tip that is +true for you on every road home (`esc` from a conversation, `/home`, `alt+1`, `tab`), in a +fixed order, round and round. Left at rest, it moves on by itself after two minutes; a home +nobody is looking at (the box being typed into, a list up) does not age, because what has +not been read has not been shown. The cross at its end puts it away until the next visit. ## Why did the hint disappear — each tip retires once you use what it teaches @@ -53,119 +48,126 @@ project has run` never comes back; run `/compact` once and the compact tip is re retired from either box is retired from both: opening the model list on home retires `/model lists every model` in every conversation as well. -A tip you never act on is not shown forever either. In a conversation, once it has been -shown in three separate sessions it is taken as read and retires by itself; on home, where -the row turns over faster, a tip retires after six turns of the rotation. Between tips in a -conversation there is always a gap of a couple of turns, so a busy first session does not -turn the border into a slideshow. +A tip you never act on is not shown forever either. Every time a row moves on to a tip +counts as one showing — a turn of home's rotation, a quiet minute in a conversation, the +two-minute turn after it — and once a tip has been shown six times it is taken as read and +retires by itself. A tip nobody could see does not count: home's row deciding while you are +in a conversation, or a conversation's before its quiet minute, is not a showing. This is remembered per profile, in a small file called `notices.json` beside `config.json` in your codeaf profile directory. Retiring is permanent: turning hints off and on does not bring a retired tip back. Deleting that file brings every tip back once; nothing else is in it. +## The tip on home changed by itself — the order the tips come round in, and the tip that jumps the queue + +Both rows take turns through the one list, in the order below, round and round: every tip +that is true for you gets its turn before any repeats, and a tip that stops being true +stands down at once for the next. Nothing outranks anything — with one exception. **A tip +that has just become true jumps the queue**: when a conversation crosses half its context +window, `/compact summarizes the conversation now` is said next rather than forty minutes +later when the ring comes round. It jumps once and then takes its turn like the rest. + ## Every hint codeaf can show, and what makes each one go away -There are thirty. Each one says where it can appear — in a conversation's keys row, on -home's row above the rule, or both — the moment it first appears, and the gesture that -retires it. The list is the program's own table (the surface refuses to build if the two -disagree), so a tip you saw is on it word for word. +There are thirty, one list for both boxes. Each one says the moment it first appears and +the gesture that retires it. The list is the program's own table (the surface refuses to +build if the two disagree), so a tip you saw is on it word for word. **Starting work** -- `/ask answers right here without opening a conversation` — home only, whenever home's - ask door is there. Retired the first time `/ask` or `alt+enter` sends something from home. -- `/task starts work you can walk away from` — conversation only, after the first exchange. - Retired when `/task` is typed, bare or with a brief. -- `ctrl+enter sends your message as something to keep true` — both. Retired when a standing +- `/compact summarizes the conversation now` — when the conversation passes half its + context window. Retired when a `/compact` finishes. +- `/cost says what this conversation has spent` — once the conversation has spent about + ten cents. Retired when you run `/cost`. +- `ctrl+. sees every task this project has run` — after the first task starts. Retired + when you open the task page, by `ctrl+.` or `/history`. +- `/rewind takes back an earlier message` — after an answer of about 1,500 characters or + more. Retired the first time a rewind lands. +- `/files finds everything made for you` — after the first export writes a file. Retired + when you run `/files`. +- `/resume opens an earlier conversation` — when you start in a directory that already has + a conversation. Retired when you run `/resume`. +- `/standing keeps something always true` — once this directory has three or more earlier + conversations. Retired when a standing order is made or the standing page opened. +- `/ask answers right here without opening a conversation` — on home, whenever home's ask + door is there (it is the one tip that is only true on home). Retired the first time + `/ask` or `alt+enter` sends something from home. +- `/task starts work you can walk away from` — after the first exchange. Retired when + `/task` is typed, bare or with a brief. +- `ctrl+enter sends your message as something to keep true` — retired when a standing order is made or the standing page opened. -- `/standing keeps something always true` — both, once this directory has three or more - earlier conversations. Retired by the same gesture; it is the quietest and yields to every - other in a conversation. -- `/manual answers any question about codeaf from its own manual` — both. Retired when +- `/manual answers any question about codeaf from its own manual` — retired when `/manual` is typed, bare, with a page or with a question. +- `ctrl+shift+t reopens the tab you just closed` — retired the first time the chord is + pressed, on a terminal that can send it. **Files and context** -- `@ completes a file, a folder or a task into your message` — both. Retired when the `@` - list opens. -- `/attach sends a file along with your message` — both. Retired when a file goes on the - tray by path or the file browser opens. -- `/attach takes a picture too, or paste a screenshot in` — both. Retired by the same gesture. -- `/folder picks the folder codeaf works in` — both. Retired when the folder chooser opens, - from a conversation or aimed at home's target. -- `/export writes this whole conversation to a file` — conversation only, after two - exchanges. Retired when an export lands. -- `/files finds everything made for you` — both, after the first export writes a file. - Retired when you run `/files`. +- `@ completes a file, a folder or a task into your message` — retired when the `@` list + opens. +- `/attach sends a file along with your message` — retired when a file goes on the tray by + path or the file browser opens. +- `/folder picks the folder codeaf works in` — retired when the folder chooser opens, from + a conversation or aimed at home's target. +- `/attach takes a picture too, or paste a screenshot in` — retired by the same gesture as + the other `/attach` tip. +- `/export writes this whole conversation to a file` — after two exchanges. Retired when + an export lands. **Models, thinking and cost** -- `/model lists every model, /model switches at once` — both. Retired when the model - list opens, over a conversation or over home's draft. -- `/crew sets the models codeaf uses on its own behalf` — both. Retired when `/crew` - answers, bare or with a preset. -- `/budget caps what today may cost` — both. Retired when `/budget` answers. -- `alt+3 shows what this machine has spent, by the day` — both. Retired when the spend - place opens by any door. -- `/cost says what this conversation has spent` — both, once the conversation has spent - about ten cents. Retired when you run `/cost`. -- `/compact summarizes the conversation now` — both, when the conversation passes half its - context window. Retired when a `/compact` finishes. +- `/model lists every model, /model switches at once` — retired when the model list + opens, over a conversation or over home's draft. +- `/crew sets the models codeaf uses on its own behalf` — retired when `/crew` answers, + bare or with a preset. +- `/budget caps what today may cost` — retired when `/budget` answers. +- `alt+3 shows what this machine has spent, by the day` — retired when the spend place + opens by any door. **Steering a running answer** -- `enter while an answer is coming stops it and steers` — conversation only, after the - first exchange. Retired the first time you steer. -- `ctrl+q queues this message for after the current turn` — conversation only, after the - first exchange. Retired the first time you queue one. -- `/rewind takes back an earlier message` — both, after an answer of about 1,500 characters - or more. Retired the first time a rewind lands. +- `enter while an answer is coming stops it and steers` — after the first exchange. + Retired the first time you steer. +- `ctrl+q queues this message for after the current turn` — after the first exchange. + Retired the first time you queue one. **Moving around** -- `ctrl+t starts a fresh chat in this folder` — both. Retired when the new-chat page opens. -- `alt+1 to alt+7 jump straight to a place` — both. Retired the first time a place chord - reaches one. -- `ctrl+shift+t reopens the tab you just closed` — both. Retired the first time the chord - is pressed, on a terminal that can send it. -- `ctrl+. sees every task this project has run` — both, after the first task starts. - Retired when you open the task page, by `ctrl+.` or `/history`. -- `/resume opens an earlier conversation` — both, when you start in a directory that - already has a conversation. Retired when you run `/resume`. +- `ctrl+t starts a fresh chat in this folder` — retired when the new-chat page opens. +- `alt+1 to alt+7 jump straight to a place` — retired the first time a place chord reaches + one. **Memory, accounts and the rest** -- `/remember keeps one thing across conversations` — both. Retired when `/remember` is - typed. -- `/search finds anything ever said on this machine` — both. Retired when the search place - opens by any door. -- `/subharness lists the programs you can run` — both. Retired when `/subharness` is typed, - bare or with a name. -- `/connect links Google, Slack or another model service` — both. Retired when the connect - panel is reached for. -- `ask for a picture, a voiceover, music or a video` — both. Retired the first time the - session begins making one. +- `/remember keeps one thing across conversations` — retired when `/remember` is typed. +- `/search finds anything ever said on this machine` — retired when the search place opens + by any door. +- `/subharness lists the programs you can run` — retired when `/subharness` is typed, bare + or with a name. +- `/connect links Google, Slack or another model service` — retired when the connect panel + is reached for. +- `ctrl+b freezes the screen so you can read and copy from it` — after the first exchange. + Retired the first time copy mode opens. -When two are relevant at once in a conversation the more useful one wins — the compact tip -over the cost tip, the cost tip over the task page tip — and the other waits its turn. On -home nothing wins: every tip that is true for you has its turn, in the order above. +Unless a line above says otherwise, a tip is true from the first minute on home and after +the first exchange in a conversation. `/ shows every command` used to be one of these. It is gone because both keys rows now say -`/ commands` outright, so there was nothing left to teach. Two more were cut on 2026-09-22: -an `alt+enter` tip that promised a task where the chord asks, and a `ctrl+r` tip for a -chord that works only in a conversation and only over a making-shaped sentence. +`/ commands` outright, so there was nothing left to teach. Three more were cut on +2026-09-22: an `alt+enter` tip that promised a task where the chord asks, a `ctrl+r` tip +for a chord that works only in a conversation and only over a making-shaped sentence, and +`ask for a picture, a voiceover, music or a video`, whose seat the copy-mode tip took. ## Turn off hints — stop showing tips, disable the hints, the disable hints row Open the settings panel with `/settings` (or `ctrl+,`), go to the **Workspace** tab, and flip the **disable hints** row on. Enter or space toggles it; it is off by default, which means the tips show. (Until 2026-09-22 it was a **hints** row on the Display tab, on by default.) -The change lands at the end of the next turn. On silences the tips — in the conversation's -keys row and on home's row alike — and the what's-new lines together; it does not touch the -keys the slot names for a live state — `ctrl+c interrupt` and the rest are not hints and -cannot be turned off. From the terminal, `codeaf config` shows the same row under the same -name. +The change lands at the end of the next turn. On silences the tips — over a conversation's +box and over home's alike — and the what's-new lines together; it does not touch the keys +row's own words for a live state — `ctrl+c interrupt` and the rest are not hints and cannot +be turned off. From the terminal, `codeaf config` shows the same row under the same name. Turning the row back off shows whatever is due. Tips you had already retired stay retired. diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index 1364213a81..16361f1baa 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -1280,13 +1280,15 @@ message. `/attach ~/shots/shot.png` is the same road for a picture. **A drop does the same thing without a command.** Drag a file onto the window while home is up and it lands on the same tray. So does a paste. -**A bare `/attach` asks for the path where you typed it**: `type the path after /attach · or -drop the file here`. If what you want is to *browse* for something, `/folder` opens the -browser — see *Change the model before starting* for what that sheet does on home. +**A bare `/attach` opens the browser** (since 2026-09-22) — the same sheet a bare `/folder` +opens, aimed at the folder the next conversation opens in; a file chosen there lands on +home's tray. Choosing `/attach` on the `/` list with `enter` opens it at once; the +`/attach ` row under it is for a typed path. (It used to answer `type the path after +/attach · or drop the file here`.) **A folder after `/attach` is not a file.** `/attach ~/src/parser` on home pins the next conversation's folder — the same decision `/folder` makes — and updates the project -path on the seam. +path at the right end of the keys row. **The tray belongs to you, not to a conversation.** It survives walking into a conversation and back out to home, and the chips you put on it here are the chips the next conversation @@ -1388,9 +1390,11 @@ opening a conversation`, `alt+1 to alt+7 jump straight to a place`. It is drawn the box is empty and nothing else is up, it moves on to the next tip every time you come to home and every two minutes at rest, and each tip goes away for good the first time you do what it names. It sits at the right, led by a bulb and closed by a small cross a click puts -it away with until your next visit. The keys row at the very foot is not a tip and never -changes. The whole list, what makes each one appear and disappear, and the **disable hints** -row on the Workspace tab that turns them off, are on the *hints and tips* page. +it away with until your next visit. A conversation has the same row over its own box, from +the same one list of tips, drawn once you have been quiet there for a minute. The keys row +at the very foot is not a tip and never changes. The whole list, what makes each one appear +and disappear, and the **disable hints** row on the Workspace tab that turns them off, are on +the *hints and tips* page. ## Typing @ on home — does the @ file list work on home, complete a path into home's box diff --git a/internal/manual/chat/places.md b/internal/manual/chat/places.md index bba991f9a1..db19da729c 100644 --- a/internal/manual/chat/places.md +++ b/internal/manual/chat/places.md @@ -206,12 +206,13 @@ alt+p project · alt+e effort · alt+a approvals · / commands project: ~ At the left it says **what model** answers, then a colon and **how hard it thinks** (the rung or `auto`, without a badge), and **what it runs without asking** (`◇` and `asks`, `guardian`, `YOLO` or `refuses` — the same words the approvals chip uses inside a conversation). The model is always bold and bright cyan, -on home and in conversations. In a conversation the seam's far right names that -conversation's workspace; on home, `project: ` is at the right end of the **keys row -under the box** instead, naming where the next conversation opens (it left the rule on -2026-09-22). The keys keep their room: a long path truncates on the right, and the field -disappears if there is less than a word of room. The dim line above the rule, when there is -one, is a tip (see *hints and tips*). The bottom row names the available project, effort +on home and in conversations. On both boxes `project: ` is at the right end of the +**keys row under the box** (it left the rule on 2026-09-22): home's names where the next +conversation opens, a conversation's names its own workspace. The keys keep their room: a +long path truncates on the right, and the field disappears if there is less than a word +of room. The dim line above the rule, when there is one, is a tip (see *hints and tips*) +— on home from the first minute, in a conversation once you have been quiet for a +minute. The bottom row names the available project, effort and approval controls; the cells can also be pressed: | cell | chord | or | @@ -606,7 +607,7 @@ On a machine that has spent nothing the place is its heading `spend` over one li `every chat and task is priced here as it runs`. A window paged onto a quiet fortnight is a different thing — its head row stays, with the arrows that page it back. -## search — finding anything said or run +## search — finding anything said or run, and what it matches when memory is off Everything that has been said on this machine. `alt+7` opens it — it is not on the tab bar — and typing searches: the @@ -636,12 +637,20 @@ With nothing typed the place is its heading `search` over one line saying what t **A search that finds nothing says what to do about it**: `nothing on this machine says "amber rail" · try fewer words, or a name`. -**And a window with no index behind it says so** rather than reporting an empty result: -`there is no index of this machine's conversations behind this window, so nothing can be -searched from here.` — which is a different sentence from "nobody has said that", and the -difference matters. Over `--host` the sentence names the machine instead: the index is the -one this machine's conversations were written into, and the conversation you are in was -written on the other one. +**With memory off, the place searches by name instead of refusing** (since 2026-09-22). +What was said is indexed only while the **memory** row is on — memory off opens no store +at all — so on such a machine the search place matches conversations the way home's box +does: every word you type has to appear in the conversation's name, its project's name or +its folder's name, and the matches come newest first with the project and the age but no +quoted turn. The empty place says so under its whisper: `what was said is not indexed while +memory is off · conversations match by their name and project`. A miss says +`no conversation on this machine is named "amber rail" · what was said is not indexed while +memory is off` — a different sentence from "nobody has said that", and the difference +matters. (Until 2026-09-22 this window said `there is no index of this machine's +conversations behind this window, so nothing can be searched from here.` and searched +nothing.) Over `--host` a sentence at the top names the machine: the index is the one this +machine's conversations were written into, and the conversation you are in was written on +the other one. Typing here searches and nothing else. **Typing on home is what offers places** (`sta` offers the standing place beside the chats that match) — the same offer made twice, one `tab` apart, diff --git a/internal/manual/chat/screen.md b/internal/manual/chat/screen.md index 5739693842..5164439c4e 100644 --- a/internal/manual/chat/screen.md +++ b/internal/manual/chat/screen.md @@ -66,14 +66,17 @@ naming the four (and the one you stand in, when it is off the bar), a dim rule, the hint line last. See the **Places** page. **Only home has a box under that rule.** Its seam starts with the model, a colon and -its effort word and approvals, with the project at the far right: -`z-ai/glm-5.3-flash:auto · ◇ asks ─── project: ~/codeaf`. Conversation seams use the same -layout and retain the full model identifier, including the organization before `/` -(for example, `deepseek/deepseek-v4.1-flash`). They name the current workspace after any telemetry on the right. Home's project is clickable to cycle the draft -destination; the conversation's is a reading. Model names and project paths underline -on mouse-over on both seams; the model stays bold and bright. Paths truncate on the right, and the -project field disappears if the controls and telemetry leave too little room. The model stays -bold and bright cyan on home and in conversations, and the effort has no badge. +its effort word and approvals: `z-ai/glm-5.3-flash:auto · ◇ asks`. Conversation seams use +the same layout and retain the full model identifier, including the organization before +`/` (for example, `deepseek/deepseek-v4.1-flash`), with the numbers after it on the right. +**The project is not on either seam since 2026-09-22**: `project: ` is at the right +end of the **keys row under the box**, on home and in a conversation alike — home's names +where the next conversation opens, a conversation's names its own workspace — and both are +doors onto the folder chooser (a click, or `alt+p` on home and `/folder` in a +conversation). Model names on the seam and project paths on the keys row underline on +mouse-over; the model stays bold and bright. Paths truncate on the right, and the project +goes entirely where the keys leave less than a word of room. The model stays bold and +bright cyan on home and in conversations, and the effort has no badge. The box says `› type to search or start something new`. Its bottom row carries `alt+p project · alt+e effort · alt+a approvals · alt+k chats · / commands` when those controls are available. `/model` or a press on the model opens the list; @@ -681,9 +684,13 @@ answering and where** on the left, and **the numbers** — the bill, the meter, word — on the right, like the legend on a fieldset: ``` -─ glm-5.3-flash (deepinfra):high · ◇ asks ── $0.27 · 58% cached 66.8k/1.3M · 5% ⠹ working · 12s project: ~/src/parser ─ +─ glm-5.3-flash (deepinfra):high · ◇ asks ── $0.27 · 58% cached 66.8k/1.3M · 5% ⠹ working · 12s ─ + › your sentence + alt+e effort · alt+a approvals · alt+k chats · / commands project: ~/src/parser ``` +The project is on the keys row under the box (since 2026-09-22), not on the rule. + **The conversation's name is not on this line.** It was, from 2026-09-09 to 2026-09-17, and it came off because a title takes the room the numbers need: the name is on the tab strip at the top of the frame and on the breadcrumb bar, and nowhere else. Nothing stands @@ -759,7 +766,9 @@ long title could never push the numbers off the frame, and the name moved off ag **The keys row under the box** — the last row of the frame — is the hint slot. Until 2026-09-17 it was the right end of the rule above the box; the numbers took that end and -the keys got a row of their own. It names the keys that work right now when a state has +the keys got a row of their own. Since 2026-09-22 it carries `project: ` at its right +end and never an earned tip — the tips stand on the row above the rule, after a quiet +minute (see *hints and tips*). It names the keys that work right now when a state has keys of its own — for example `y allow · n deny · a always` while a question is up, `ctrl+c interrupt` while a turn is running, `enter steers it in · ctrl+shift+enter stops and sends · ctrl+c interrupt` while a turn is @@ -1347,9 +1356,10 @@ Under 60 columns, eight things change shape: at most three wide targets: `open` on the inbox, `‹ back · open · more` on a sheet. A tap **opens** — there is no second column to preview into, so there is no two-step — and mouse motion is ignored. Below width **24** the plain - hint line is drawn instead of the bar. The rule over the box still says where the next - conversation goes, with model and effort first, then approvals, and `project: ` at the right. - The path truncates on the right, and controls give way whole on narrow frames. No arrow + hint line is drawn instead of the bar. The rule over the box still says what the next + conversation answers on, with model and effort first, then approvals; where it goes is + `project: ` at the right end of the keys row under the box, cut on the right where + the keys leave it too little room. Controls give way whole on narrow frames. No arrow or `new conversation in` lead is drawn. The phone's action bar owns the keys. 8. **The task strip becomes one door, and the roster becomes cards.** The strip stops diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index f1e3440924..9cf7be3120 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -2378,6 +2378,10 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { {"the tip on home changed by itself", "hints-and-tips"}, {"every hint codeaf can show", "hints-and-tips"}, {"is a retired tip gone for good", "hints-and-tips"}, + {"a tip appeared in my conversation after a while", "hints-and-tips"}, + {"why does the hint only show up when I stop typing", "hints-and-tips"}, + {"can I search my conversations with memory off", "places"}, + {"search says what was said is not indexed", "places"}, // The wave that made the places follow the session's machine. These are // the owner's own sentences, from the report that started it: they diff --git a/internal/tui3/app.go b/internal/tui3/app.go index bab0a43d74..f77e4939ff 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -3206,6 +3206,12 @@ func (a *app) Init() tea.Cmd { standing = append(standing, a.wake()) } } + // THE CONVERSATION'S TIP CLOCK IS STARTED HERE, once, and keeps itself + // going (notice.go's THE CONVERSATION'S CLOCK). It is the one long-period + // clock this surface runs — a minute at a time, never a frame — and it + // stands in the same flat batch as the rest, because a test reads that + // batch one level deep for the terminal's colour question (adaptive_test.go). + standing = append(standing, a.noticeArmIdle()) return tea.Batch(standing...) } @@ -3342,6 +3348,11 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyPressMsg: a.sawAPerson() + // AND THE CONVERSATION'S TIP CLOCK IS STAMPED HERE TOO, on the same + // argument: a key is the proof somebody is doing something, and a tip + // over a conversation waits for a minute of nobody doing anything + // (notice.go's [app.noticeTouched]). + a.noticeTouched() // AND THE HAND IS STAMPED HERE, for the same reason the line above is: // this is the only line every keypress passes through, and what the // question block needs to know is whether somebody is at the keyboard @@ -3994,6 +4005,12 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { if a.jumpPress(msg.Mouse().X, msg.Mouse().Y) { return a, nil } + // AND THE CROSS ON THE TIP ROW RIDES THE SAME GAP, when the chip does + // not: a press on it puts the tip away (projectseam.go's + // [app.tipClosePress]). + if a.tipClosePress(msg.Mouse().X, msg.Mouse().Y) { + return a, nil + } // AND THE DOOR HOME IS THE THIRD, in the hint slot at the right end // of the legend. Column-aware for the same reason again: the rest of // that rule is a rule, and pressing a rule means nothing (home.go). @@ -4017,6 +4034,12 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { if cmd, took := a.legendApprovalPress(msg.Mouse().X, msg.Mouse().Y); took { return a, cmd } + // AND THE PROJECT AT THE RIGHT END OF THE KEYS ROW IS THE SEVENTH: + // pressing it opens the folder chooser, the door `/folder` is + // (projectseam.go's [app.seamProjectPress]). + if cmd, took := a.seamProjectPress(msg.Mouse().X, msg.Mouse().Y); took { + return a, cmd + } // THE STOP TARGETS ARE READ BEFORE EVERY OTHER COLUMN-AWARE PRESS // (stop.go). The card's answers sit over the draft, and the ✕ sits at // the right end of the room's pinned header with a hit box three rows @@ -4504,6 +4527,12 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { // without touching the filter somebody is typing (folderplace.go). return a, a.tookFolderStore(msg) + case hintTickMsg: + // THE CONVERSATION'S TIP CLOCK, landing: a minute of nobody doing + // anything shows the row's tip, and every two minutes after moves it on + // (notice.go's THE CONVERSATION'S CLOCK). + return a, a.noticeIdleBeat(msg.gen) + case homeTickMsg: // HOME IS LIVE, and this is the whole of how: read the folders again, // then ask for one more beat. It rides its own clock rather than the @@ -5271,11 +5300,6 @@ func (a *app) applyEvent(ev session.Event, lump bool) tea.Cmd { default: a.collapseThought() } - // A PICTURE, A VOICE, MUSIC OR FILM BEGINNING is the proof the person knows - // to ask for one (notice.go's [mediaTools]). - if ev.Kind == session.EventToolBegin && mediaTools[ev.Tool] { - a.noticeEvent(eventMediaAsked) - } // THE WAIT CLOCK IS ANCHORED HERE, on both edges, before anything else reads // it. The two lists below are the whole of what the surface knows about a // model request's life, and they are kept together so the pair cannot drift. @@ -5736,6 +5760,10 @@ func (a *app) settle() tea.Cmd { // A turn ending is the moment most hints become true — the answer was long, // the window is half full, the money is real — so it is the event they are // decided on (notice.go). + // AND A TURN ENDING IS THE OTHER THING THAT STAMPS THE TIP CLOCK: the + // answer that just landed is what the person is reading now, and the + // row over the box waits its minute from here ([app.noticeTouched]). + a.noticeTouched() a.noticeEvent(eventTurnEnded) a.follow() a.touch() diff --git a/internal/tui3/attach.go b/internal/tui3/attach.go index 814c288e03..9428b76497 100644 --- a/internal/tui3/attach.go +++ b/internal/tui3/attach.go @@ -386,6 +386,14 @@ func (a *app) resolvePath(path string) string { // that word, and anything else is left exactly as typed. func unquotePath(path string) string { path = strings.TrimSpace(path) + // ONLY A PATH THAT IS SPELLED THE SHELL'S WAY IS READ THE SHELL'S WAY: one + // that opens with a quote, or carries a backslash escape. `owner's + // report.log` typed plainly has an apostrophe in its NAME, and reading that + // as an open quote swallowed it (the drop road had already unquoted the + // terminal's spelling before this was asked, dropkeys_test.go). + if !strings.HasPrefix(path, "'") && !strings.HasPrefix(path, "\"") && !strings.Contains(path, "\\") { + return path + } if words := pastedWords(path); len(words) == 1 && words[0] != "" { return words[0] } diff --git a/internal/tui3/boxseam_test.go b/internal/tui3/boxseam_test.go index d6d11dc8b9..bdd64c1c1f 100644 --- a/internal/tui3/boxseam_test.go +++ b/internal/tui3/boxseam_test.go @@ -239,29 +239,21 @@ func TestThePinsRideOntoTheConversationAndTheGateIsSpent(t *testing.T) { // ── 4. the ladder ─────────────────────────────────────────────────────────── -// A long project yields its tail before the controls, and the project door -// follows its position at the right edge of the rendered line. -func TestTheDraftRuleKeepsTheProjectAtTheRight(t *testing.T) { +// The draft rule carries the model, the rung and the gate, and no longer the +// project: that is the keys row's since 2026-09-22 (hometiplayout_test.go +// proves the row and its door). +func TestTheDraftRuleKeepsTheModelAndNotTheProject(t *testing.T) { _, a := drafting(t) a.showPage(pageHome) a.model = "moonshotai/kimi-k3" a.target.where = "/tmp/landing-test" line, drew := a.targetLegend(120, a.pal) text := ansi.Strip(line) - if !drew || !strings.HasPrefix(text, "─ moonshotai/kimi-k3:high · ") || !strings.HasSuffix(text, " project: /tmp/landing-test ─") { - t.Fatalf("the draft seam has the wrong order: %q", text) + if !drew || !strings.HasPrefix(text, "─ moonshotai/kimi-k3:high · ") || strings.Contains(text, targetProjectLead) { + t.Fatalf("the draft seam has the wrong shape: %q", text) } - if a.targetModelSpan.from != 2 || a.targetFolderSpan.from <= a.targetApprovalSpan.to { - t.Fatalf("the seam's doors did not move with it: model %+v, project %+v", a.targetModelSpan, a.targetFolderSpan) - } - if got := ansi.Cut(text, a.targetFolderSpan.from, a.targetFolderSpan.to); got != "/tmp/landing-test" { - t.Fatalf("the project door covers %q", got) - } - a.target.where = "/tmp/" + strings.Repeat("long-project/", 12) - line, drew = a.targetLegend(80, a.pal) - text = ansi.Strip(line) - if !drew || ansi.StringWidth(text) != 80 || !strings.HasPrefix(text, "─ moonshotai/kimi-k3:high · ") || !strings.Contains(text, "project: /tmp/") || !strings.Contains(text, "… ─") { - t.Fatalf("the long project displaced controls or lost its root: %q", text) + if a.targetModelSpan.from != 2 { + t.Fatalf("the seam's model door did not move with it: %+v", a.targetModelSpan) } for width := 1; width <= 120; width++ { line, _ := a.targetLegend(width, a.pal) @@ -271,29 +263,24 @@ func TestTheDraftRuleKeepsTheProjectAtTheRight(t *testing.T) { } } -// A conversation names its own workspace in the same position as home's -// draft destination. A pin for the next conversation must not relabel this one. -func TestConversationProjectStaysAtTheRightOfTheSeam(t *testing.T) { +// A conversation's seam no longer names its workspace: the project is at the +// right end of the keys row under the box (chattip_test.go proves the row), +// and a pin for the next conversation must not relabel this one anywhere. +func TestConversationProjectHasLeftTheSeam(t *testing.T) { _, a := gated(t) a.tilde, a.workspace = "/home/person", "/home/person/projects/parser" a.target.where = "/tmp/next-project" - text := ansi.Strip(a.legend(240)) - want := " project: ~/projects/parser ─" - if !strings.HasSuffix(text, want) || strings.Contains(text, "next-project") { - t.Fatalf("conversation seam does not name its own project at the right: %q", text) - } for width := 1; width <= 240; width++ { line := ansi.Strip(a.legend(width)) if ansi.StringWidth(line) > width { t.Fatalf("at %d cells the conversation seam overflowed: %q", width, line) } - if strings.Contains(line, targetProjectLead) && !strings.Contains(line, targetProjectLead+"~/") { - t.Fatalf("at %d cells the project lost its root: %q", width, line) + if strings.Contains(line, targetProjectLead) { + t.Fatalf("at %d cells the seam still names the project: %q", width, line) } } - a.workspace = "" - if text := ansi.Strip(a.legend(240)); strings.Contains(text, targetProjectLead) { - t.Fatalf("unknown project left a label behind: %q", text) + if text := ansi.Strip(a.hintRow(240)); strings.Contains(text, "next-project") { + t.Fatalf("the keys row names the next conversation's folder: %q", text) } } diff --git a/internal/tui3/bundle_test.go b/internal/tui3/bundle_test.go index c2e6117657..9e5ff1908f 100644 --- a/internal/tui3/bundle_test.go +++ b/internal/tui3/bundle_test.go @@ -1791,9 +1791,14 @@ func TestTheSeamCarriesTheModelAndTheInputsAffordances(t *testing.T) { if keys := plain(a.hintRow(100)); !strings.Contains(keys, microcopy) { t.Fatalf("the keys row is missing %q:\n%q", microcopy, keys) } - // The model keeps its provider prefix, and the project follows the telemetry. - if !strings.Contains(line, "project: ~/src/codeaf") { - t.Fatalf("the legend lost its project: %q", line) + // The model keeps its provider prefix, and the project is on the keys row + // since 2026-09-22 (footswap.go's [app.hintRow]) rather than after the + // telemetry. + if strings.Contains(line, targetProjectLead) { + t.Fatalf("the legend still carries the project: %q", line) + } + if keys := plain(a.hintRow(100)); !strings.HasSuffix(strings.TrimRight(keys, " "), "project: ~/src/codeaf") { + t.Fatalf("the keys row lost its project: %q", keys) } if !strings.HasPrefix(line, "─ ") || !strings.HasSuffix(line, " ─") { t.Fatalf("the label is not sitting inside a border: %q", line) @@ -1980,9 +1985,12 @@ func TestTheStatusRowIsALedgerLeftAndAlivenessRight(t *testing.T) { if cost < 0 || meter < cost || state < meter { t.Fatalf("the groups are out of order:\n%q", line) } - // THE KEYS ROW CARRIES NO FACT AT ALL — not the identity, not the numbers - // (footswap.go). + // THE KEYS ROW CARRIES NO FACT BUT THE PROJECT at its right end — not the + // identity, not the numbers (footswap.go's [app.hintRow]). keys := plain(a.hintRow(200)) + if at := strings.Index(keys, targetProjectLead); at >= 0 { + keys = keys[:at] + } for _, banned := range []string{"porting the parser", "deepseek-v4-flash", "deepseek/", product, "$0.14", "idle"} { if strings.Contains(keys, banned) { t.Fatalf("the keys row is carrying %q: %q", banned, keys) @@ -2003,9 +2011,10 @@ func TestTheStatusRowIsALedgerLeftAndAlivenessRight(t *testing.T) { if !strings.Contains(line, "12.4k/128k · 10%") { t.Fatalf("the meter's own halves are not joined by a dot: %q", line) } - // The project follows the state at the right edge of the seam. - if !strings.HasSuffix(line, "idle project: ~/src/codeaf ─") { - t.Fatalf("the project does not follow the state: %q", line) + // The state word is the last thing on the seam; the project is the keys + // row's since 2026-09-22. + if !strings.HasSuffix(line, "idle ─") { + t.Fatalf("the state word is not the last thing on the seam: %q", line) } } @@ -2506,8 +2515,14 @@ func TestTheHudLaysOutAtEveryWidth(t *testing.T) { if strings.Contains(legend, "the bottom hud wave") { t.Fatalf("at %d columns the seam carries the name: %q", tc.width, legend) } - if tc.width == 200 && !strings.Contains(legend, "project: ~/src/codeaf") { - t.Fatalf("the wide legend lost its project: %q", legend) + // The project is the keys row's since 2026-09-22 (footswap.go). + if strings.Contains(legend, targetProjectLead) { + t.Fatalf("at %d columns the seam still carries the project: %q", tc.width, legend) + } + if tc.width == 200 { + if keys := plain(a.hintRow(tc.width)); !strings.Contains(keys, "project: ~/src/codeaf") { + t.Fatalf("the wide keys row lost its project: %q", keys) + } } } } diff --git a/internal/tui3/chattip_test.go b/internal/tui3/chattip_test.go new file mode 100644 index 0000000000..090db9f8e3 --- /dev/null +++ b/internal/tui3/chattip_test.go @@ -0,0 +1,353 @@ +package tui3 + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/Agent-Field/codeaf/internal/tui2/tokens" +) + +// ── THE CONVERSATION'S TIP ROW, ITS KEYS ROW'S PROJECT, AND TWO DOORS ──────── +// +// Since 2026-09-22 a conversation says its tips the way home does — on the row +// over the rule, right-aligned, with a bulb and a cross — and only once the +// person has been quiet for a minute (notice.go's THE CONVERSATION'S CLOCK). +// The project came down off the seam to the right end of the keys row, as it +// did on home. And two of the owner's bug reports from the same day: enter on +// `/attach` in the list opens the browser at once, and the search place finds +// conversations by name when memory is off. + +// chatTipLab is a conversation over a clock the test turns by hand. +func chatTipLab(t *testing.T) (*app, func(time.Duration)) { + t.Helper() + a, _ := sheetApp(t) + now := time.Date(2026, 9, 22, 10, 0, 0, 0, time.UTC) + a.clock = func() time.Time { return now } + return a, func(d time.Duration) { now = now.Add(d) } +} + +// tipRowOf is the frame row carrying the tip, and -1 when none does. +func tipRowOf(a *app, tip string) (int, []string) { + rows := strings.Split(plain(frame(a)), "\n") + for i, row := range rows { + if strings.Contains(row, tip) { + return i, rows + } + } + return -1, rows +} + +// A conversation's row says nothing until the person has been quiet for +// [chatHintIdle]; the one clock measures from the last key; the row then draws +// over the rule with the bulb and the cross, the keys row does not carry it, +// the cross puts it away, the next beat moves the row on, and a key hides it +// again until the next quiet minute. +func TestAConversationSaysATipOnlyAfterAQuietMinute(t *testing.T) { + a, advance := chatTipLab(t) + b := &a.notices + if a.noticeArmIdle() == nil { + t.Fatal("the surface coming up did not start the clock") + } + if a.noticeArmIdle() != nil { + t.Fatal("a second start armed a second clock") + } + startTask(t, a) + if b.current[slotHint] != "task-page-after-first-task" { + t.Fatalf("a task starting armed %q", b.current[slotHint]) + } + if got := a.noticeHint(); got != "" { + t.Fatalf("the tip drew before a quiet minute: %q", got) + } + // A BEAT BEFORE THE MINUTE GOES BACK TO SLEEP for what is left. + advance(30 * time.Second) + if cmd := a.noticeIdleBeat(b.idleGen); cmd == nil || b.due { + t.Fatal("a beat inside the minute did not go back to sleep") + } + // A KEY STAMPS THE CLOCK AGAIN, so the minute is measured from it. + drive(t, a, key("x"), key("backspace")) + advance(45 * time.Second) + if cmd := a.noticeIdleBeat(b.idleGen); cmd == nil || b.due { + t.Fatal("the beat did not measure the minute from the last key") + } + advance(chatHintIdle) + if cmd := a.noticeIdleBeat(b.idleGen); cmd == nil || !b.due { + t.Fatal("a quiet minute did not make the tip due") + } + if got := a.noticeHint(); got != taskPageTip { + t.Fatalf("after a quiet minute the row reads %q, want the tip", got) + } + // ON THE FRAME: the row directly over the rule, right-aligned, bulb and cross. + y, rows := tipRowOf(a, taskPageTip) + if y < 0 { + t.Fatalf("the tip is not on the frame:\n%s", strings.Join(rows, "\n")) + } + row := strings.TrimRight(rows[y], " ") + cross := a.pal.glyph(tokens.GFailed) + if !strings.HasSuffix(row, homeTipLead+homeTipGap+taskPageTip+homeTipGap+cross) { + t.Fatalf("the tip row does not end with the bulb, the tip and the cross: %q", row) + } + if got := ansi.StringWidth(row); got != a.width-1 { + t.Fatalf("the tip row measures %d cells on a %d-cell frame, want %d", got, a.width, a.width-1) + } + if y+1 >= len(rows) || !strings.HasPrefix(rows[y+1], "─") { + t.Fatalf("the rule is not the row under the tip:\n%s", strings.Join(rows, "\n")) + } + if got := a.footHint(a.width); strings.Contains(got, taskPageTip) { + t.Fatalf("the keys row still carries the tip: %q", got) + } + // THE CROSS. A press on it puts the tip away; a press beside it does not. + if !a.tipCloseSpan.pressable() { + t.Fatal("the draw recorded no columns for the cross") + } + if a.tipClosePress(a.tipCloseSpan.from-4, y) { + t.Fatal("a press on the tip's words was taken as the cross") + } + if !a.tipClosePress(a.tipCloseSpan.from, y) { + t.Fatal("a press on the cross was not taken") + } + if got := a.noticeHint(); got != "" { + t.Fatalf("the cross did not put the tip away: %q", got) + } + if strings.Contains(plain(frame(a)), taskPageTip) { + t.Fatal("the tip is still drawn after its cross was pressed") + } + if b.retired("task-page-after-first-task") { + t.Fatal("putting a tip away retired it") + } + // THE NEXT BEAT BRINGS A TIP BACK — the ring has one eligible tip here, so + // it is the same one. + advance(hintEvery) + if cmd := a.noticeIdleBeat(b.idleGen); cmd == nil { + t.Fatal("the beat after a quiet minute did not re-arm") + } + if got := a.noticeHint(); got != taskPageTip { + t.Fatalf("the beat did not bring the tip back: %q", got) + } + // AND A KEY HIDES IT until the next quiet minute. + drive(t, a, key("y"), key("backspace")) + if b.due || a.noticeHint() != "" { + t.Fatalf("a key did not stand the tip down: due=%v hint=%q", b.due, a.noticeHint()) + } + // A PLACE IN FRONT SLEEPS THE MINUTE AGAIN, showing nothing, and a beat + // from an older arming is dropped. + a.showPage(pageSpend) + advance(2 * chatHintIdle) + if cmd := a.noticeIdleBeat(b.idleGen); cmd == nil || b.due { + t.Fatal("a beat over a place did not sleep the minute again") + } + if cmd := a.noticeIdleBeat(b.idleGen - 1); cmd != nil { + t.Fatal("a beat from an older arming was not dropped") + } + a.leavePlace() + if a.showing() != nil { + t.Fatalf("the conversation did not come back; %v is showing", a.showing().id()) + } + advance(2 * chatHintIdle) + if cmd := a.noticeIdleBeat(b.idleGen); cmd == nil || !b.due { + t.Fatal("the conversation coming back and going quiet did not bring the tip") + } +} + +// Silencing hints silences the conversation's row along with home's. +func TestDisableHintsSilencesTheConversationRow(t *testing.T) { + a, _ := chatTipLab(t) + startTask(t, a) + a.notices.due = true + if a.noticeHint() == "" { + t.Fatal("the tip is not up before the toggle") + } + a.notices.enabled = false + if got := a.noticeHint(); got != "" { + t.Fatalf("a silenced profile still says %q in a conversation", got) + } + // The clock keeps ticking over a silenced profile, showing nothing, so + // turning hints back on needs no restart. + a.noticeArmIdle() + if cmd := a.noticeIdleBeat(a.notices.idleGen); cmd == nil { + t.Fatal("a silenced profile stopped the tip clock") + } +} + +// The project is at the right end of a conversation's keys row, off the seam, +// still a door — onto the folder chooser — and the keys keep their room. +func TestAConversationKeysRowCarriesTheProjectAtItsRight(t *testing.T) { + _, a := gated(t) + a.tilde, a.workspace = "/home/person", "/home/person/projects/parser" + a.target.where = "/tmp/next-project" + frame(a) + if text := ansi.Strip(a.legend(a.width)); strings.Contains(text, targetProjectLead) { + t.Fatalf("the seam still names the project: %q", text) + } + foot := ansi.Strip(a.hintRow(a.width)) + if !strings.HasSuffix(strings.TrimRight(foot, " "), targetProjectLead+"~/projects/parser") || strings.Contains(foot, "next-project") { + t.Fatalf("the keys row does not end with this conversation's project: %q", foot) + } + if got := ansi.StringWidth(foot); got != a.width { + t.Fatalf("the keys row measures %d cells on a %d-cell frame", got, a.width) + } + if !strings.HasPrefix(foot, " "+a.footHint(a.width)) { + t.Fatalf("the keys row does not begin with the keys: %q", foot) + } + if !a.seamProjectSpan.pressable() { + t.Fatal("the keys row recorded no columns for the path") + } + if got := ansi.Cut(foot, a.seamProjectSpan.from, a.seamProjectSpan.to); got != "~/projects/parser" { + t.Fatalf("the recorded span holds %q, want the path", got) + } + // THE DOOR: a press on the path opens the folder chooser, on the keys row + // and nowhere else. + frame(a) + y := markedRowY(a, chromeStatus, 0) + if _, took := a.seamProjectPress(a.seamProjectSpan.from, seamRowY(a)); took { + t.Fatal("a press on the seam where the path used to be still opened the chooser") + } + cmd, took := a.seamProjectPress(a.seamProjectSpan.from, y) + if !took { + t.Fatal("a press on the path was not taken as the folder door") + } + if cmd != nil { + if msg := waitOut(cmd); msg != nil { + drive(t, a, msg) + } + } + if !a.folder.open { + t.Fatal("the folder chooser did not open") + } + // THE KEYS KEEP THEIR ROOM: a long path is cut on the right, its root kept. + a.closeModals() + a.workspace = "/home/person/" + strings.Repeat("nested/", 30) + cut := ansi.Strip(a.hintRow(a.width)) + if !strings.HasPrefix(cut, " "+a.footHint(a.width)) { + t.Fatalf("a long path cost the keys a clause: %q", cut) + } + if !strings.Contains(cut, targetProjectLead+"~/nested/") || !strings.HasSuffix(strings.TrimRight(cut, " "), "…") { + t.Fatalf("a long path was not cut on the right with its root kept: %q", cut) + } + for width := 1; width <= 240; width++ { + line := ansi.Strip(a.hintRow(width)) + if ansi.StringWidth(line) > width { + t.Fatalf("at %d cells the keys row overflowed: %q", width, line) + } + } + a.workspace = "" + if text := ansi.Strip(a.hintRow(a.width)); strings.Contains(text, targetProjectLead) { + t.Fatalf("unknown project left a label behind: %q", text) + } +} + +// Enter on `/attach` in the command list opens the browser at once — in a +// conversation and on home — the way enter on `/folder` does; the row with a +// placeholder is still there for a typed path. +func TestEnterOnAttachInTheListOpensTheBrowserAtOnce(t *testing.T) { + a, _ := sheetApp(t) + drive(t, a, key("/"), key("a"), key("t"), key("t"), key("a"), key("c"), key("h")) + if !a.menu.open { + t.Fatal("typing /attach did not open the command list") + } + chosen, ok := a.menu.choice() + if !ok || chosen.name != "attach" || chosen.args != "" { + t.Fatalf("the cursor is on %q %q, want the bare /attach row", chosen.name, chosen.args) + } + drive(t, a, key("enter")) + if !a.folder.open { + t.Fatalf("enter on /attach did not open the browser; the box holds %q", a.input.String()) + } + if !a.input.empty() { + t.Fatalf("enter on /attach left %q in the box", a.input.String()) + } + // And on home, over the lab whose home can open the browser + // (homefate_test.go's [TestBareAttachAtHomeOpensTheBrowserForTheTarget]). + h, _, _ := mixedLab(t) + runCmd(h.openHome()) + drive(t, h, key("/"), key("a"), key("t"), key("t"), key("a"), key("c"), key("h")) + if !h.home.cmd.open { + t.Fatal("typing /attach on home did not open the command list") + } + drive(t, h, key("enter")) + if !h.folder.open || !h.folder.forTarget { + t.Fatalf("enter on /attach on home did not open the browser aimed at the target; the box holds %q", h.home.box.String()) + } +} + +// With no conversation store behind the window — memory off — the search place +// matches conversations by their name and project, the way home's box does, +// and says that is what it matched by. +func TestWithNoIndexTheSearchPlaceMatchesConversationsByName(t *testing.T) { + a := placeApp(t) + a.searchStore = nil + a.searchArm = func(int) tea.Cmd { return nil } + a.showPage(pageSearch) + _, world := searchFixture() + a.search.world = world + a.rebuildSearch() + if text := placeFrameText(a); !strings.Contains(text, searchByNameWord) { + t.Fatalf("the empty place does not say it matches by name:\n%s", text) + } + typeInto(t, a, "swarm") + if cmd := a.searchTick(searchTickMsg{gen: a.search.ask.gen}); cmd != nil { + t.Fatal("a search by name went out as a store read") + } + // The row carries the name as home spells it ([homeName]). + text := strings.ToLower(placeFrameText(a)) + if !strings.Contains(text, "swarm splitting") || strings.Contains(text, "lead research") { + t.Fatalf("the search by name did not find the conversation called that:\n%s", text) + } + if strings.Contains(text, searchNothingSaid("swarm")) { + t.Fatalf("a search by name claimed nothing was said:\n%s", text) + } + // A project name matches too. + a.search.query.reset() + typeInto(t, a, "leadgen") + a.searchTick(searchTickMsg{gen: a.search.ask.gen}) + if text := strings.ToLower(placeFrameText(a)); !strings.Contains(text, "leadgen") || strings.Contains(text, "swarm splitting") { + t.Fatalf("the search by name did not match on the project:\n%s", text) + } + // And nothing named that says so, without claiming nothing was said. + a.search.query.reset() + typeInto(t, a, "zzz") + a.searchTick(searchTickMsg{gen: a.search.ask.gen}) + if text := placeFrameText(a); !strings.Contains(text, "no conversation on this machine is named") { + t.Fatalf("a miss by name did not say so:\n%s", text) + } +} + +// `/search` typed on home opens the place, and typing there searches — the +// road the owner walked. +func TestSlashSearchOnHomeOpensThePlaceAndTypingSearches(t *testing.T) { + lab := newHomeLab(t) + a := lab.door("") + a.searchStore = nil + a.searchArm = func(int) tea.Cmd { return nil } + a.showPage(pageHome) + drive(t, a, key("/"), key("s"), key("e"), key("a"), key("r"), key("c"), key("h"), key("enter")) + if !a.at(pageSearch) { + t.Fatalf("/search on home did not open the search place; %v is showing", a.showing()) + } + drive(t, a, key("p"), key("a"), key("r")) + if got := a.search.query.String(); got != "par" { + t.Fatalf("typing on the search place put %q in its box", got) + } + if a.search.ask.query != "par" { + t.Fatalf("the place is answering for %q", a.search.ask.query) + } +} + +// A quoted path with spaces still reaches the tray from the list's typed row. +func TestTheTypedAttachRowStillTakesAPath(t *testing.T) { + a, _ := sheetApp(t) + dir := t.TempDir() + shot := filepath.Join(dir, "Screen Shot.png") + if err := os.WriteFile(shot, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + a.slash("/attach '" + shot + "'") + if len(a.chips) != 1 || a.chips[0].path != shot { + t.Fatalf("the typed row did not attach the picture: %+v", a.chips) + } +} diff --git a/internal/tui3/commands.go b/internal/tui3/commands.go index 7bbde93ce0..a86ce9680a 100644 --- a/internal/tui3/commands.go +++ b/internal/tui3/commands.go @@ -415,7 +415,14 @@ var commands = []command{ // they have used. /file is deliberately NOT an alias: it shares four // characters with /files one row above, and a word that narrowed the list to // both errands at once is the near-miss /history was named to avoid. - {name: "attach", args: "", desc: "attach a file · tab completes the path", alias: []string{"upload"}}, + // + // TWO ROWS, /folder'S REASON EXACTLY: the bare form is the browser and + // enter on its row opens it at once, where one row with a placeholder + // left `/attach ` in the box waiting for a path nobody had — a second + // enter to reach the sheet the word already meant (the owner met it, + // 2026-09-22). + {name: "attach", desc: "choose a file to attach · the browser opens", alias: []string{"upload"}}, + {name: "attach", args: "", desc: "…attach that file · tab completes the path"}, // AND DIRECTLY ABOVE /help, THE OTHER QUESTION SOMEBODY HAS WHEN THEY ARE // LOST. /help is what you can TYPE; this is what codeaf DOES, in the writing // codeaf is built from (manualcmd.go). They sit together because a person who diff --git a/internal/tui3/foot.go b/internal/tui3/foot.go index 84f5272a5d..6711c80310 100644 --- a/internal/tui3/foot.go +++ b/internal/tui3/foot.go @@ -528,7 +528,7 @@ func (a *app) seamPieces(width int) seamPieces { // one place the pin is written on the chrome: the status row and the phone // deck take the same word from the same function. pieces := seamPieces{host: a.host, model: a.modelWord(), - project: a.hostedPath(a.placeWord(tildePath(a.workspace, a.tilde)))} + project: a.seamProjectWord()} if pieces.model != "" { // A rung with no model beside it has nothing to be about, and the ladder // it belongs to is reached by name (`/effort`) rather than from a cell diff --git a/internal/tui3/footswap.go b/internal/tui3/footswap.go index fec3ba6306..a3eda8fcb4 100644 --- a/internal/tui3/footswap.go +++ b/internal/tui3/footswap.go @@ -221,8 +221,17 @@ func (a *app) seamTelemetryLabel(ledger, alive []hudPart) (string, string) { // in the payload grammar every hint on this surface is painted in. On a frame // with no seam the right edge's aliveness rides the same row's right, and the // keys give up their clauses before the state word gives up anything. +// +// AND THE PROJECT IS AT ITS RIGHT END, since 2026-09-22, exactly as it is on +// home's keys row (hometip.go's [app.homeFootLine]): right-justified in what +// the keys leave, cut on the right where they leave it too little, gone +// where they leave it less than a word. It came down off the seam so the +// two feet a person moves between most read the same way, and it is still +// a door — onto the folder chooser ([app.seamProjectPress]) — so its columns +// are recorded here, as the row is laid out ([app.seamProjectSpan]). func (a *app) hintRow(width int) string { a.homeDoor = hudSpan{} + a.seamProjectSpan = hudSpan{} hint := a.footHint(width) right, rightPlain := "", "" if !a.seamShowing() { @@ -254,10 +263,22 @@ func (a *app) hintRow(width int) string { if used > 0 { used++ } - if rightPlain == "" { - return line + strings.Repeat(" ", max(0, width-used)) + if rightPlain != "" { + return line + strings.Repeat(" ", max(1, width-used-ansi.StringWidth(rightPlain))) + right + } + // THE PROJECT, in what the keys leave — never inside a room, whose page + // carries the node's own identity (roomseam.go). + if project := a.seamProjectWord(); project != "" && !a.roomOpen() { + if text, span, ok := projectAtRight(project, used, width); ok { + a.seamProjectSpan = span + pad := width - 1 - used - ansi.StringWidth(text) + painted := a.paintSeamProject(text, + hudSpan{from: ansi.StringWidth(targetProjectLead), to: ansi.StringWidth(text)}, + a.hot.kind == hoverSeamProject) + return line + strings.Repeat(" ", pad) + painted + " " + } } - return line + strings.Repeat(" ", max(1, width-used-ansi.StringWidth(rightPlain))) + right + return line + strings.Repeat(" ", max(0, width-used)) } // seamAliveLabel is the right edge alone — the rate and the state word — for diff --git a/internal/tui3/helpreach_test.go b/internal/tui3/helpreach_test.go index d14d3a7633..5b424b6010 100644 --- a/internal/tui3/helpreach_test.go +++ b/internal/tui3/helpreach_test.go @@ -338,9 +338,11 @@ func TestASearchThatFindsNothingSaysWhatToDoAndAMissingIndexSaysSo(t *testing.T) if cmd := a.searchTick(searchTickMsg{gen: a.search.ask.gen}); cmd != nil { t.Fatal("a surface with no index sent a read anyway") } + // With no index the place matches by name instead (since 2026-09-22, + // chattip_test.go), and a miss says so without claiming nothing was said. page := plain(placeFrameText(a)) - if !strings.Contains(page, "no index of this machine's conversations") { - t.Fatalf("a window with no index behind it does not say so:\n%s", page) + if !strings.Contains(page, `no conversation on this machine is named "report"`) { + t.Fatalf("a window with no index behind it does not say what it matched by:\n%s", page) } if strings.Contains(page, `nothing on this machine says "report"`) { t.Fatalf("a search that never happened reported a result:\n%s", page) diff --git a/internal/tui3/home.go b/internal/tui3/home.go index 4e1da690c3..8c6112416a 100644 --- a/internal/tui3/home.go +++ b/internal/tui3/home.go @@ -3849,7 +3849,7 @@ func (a *app) homePress(x, y int) tea.Cmd { // THE CROSS ON THE TIP ROW PUTS THE TIP AWAY (hometip.go). It is read // first because its row carries no other door and moves no cursor. if a.tipRow >= 0 && y == a.tipRow && a.tipCloseSpan.holds(x) { - a.noticeHomeDismiss() + a.noticeDismiss(slotHome) return nil } // A CLICK MOVES THE CURSOR, so it is one of the two gestures that can leave diff --git a/internal/tui3/home_test.go b/internal/tui3/home_test.go index bef53af80b..352bb7cbe3 100644 --- a/internal/tui3/home_test.go +++ b/internal/tui3/home_test.go @@ -2913,8 +2913,10 @@ func TestTheListIsPaddedOffTheFoot(t *testing.T) { // 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. + // THE PADDING IS THE TIP ROW SINCE 2026-09-22 (hometip.go): the same + // row, blank whenever there is no tip, and never a row of the list. pad := len(lines) - placeFootRowsAt(h) - if got := strings.TrimSpace(ansi.Strip(lines[pad])); got != "" { + if got := strings.TrimSpace(ansi.Strip(lines[pad])); got != "" && pad != a.tipRow { 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/homefate_test.go b/internal/tui3/homefate_test.go index b6614fe999..592b336b32 100644 --- a/internal/tui3/homefate_test.go +++ b/internal/tui3/homefate_test.go @@ -77,7 +77,7 @@ func TestTheFateReadsTheArgumentWhereItChangesTheAnswer(t *testing.T) { {"folder", "", fateTargetFolder}, {"folder", "~/src", fateTargetFolder}, {"attach", "", fateTray}, - {"image", "shot.png", fateTray}, + {"attach", "shot.png", fateTray}, {"pricing", "", ""}, } { if got := homeFate(want.word, want.rest); got != want.fate { diff --git a/internal/tui3/homeslash_test.go b/internal/tui3/homeslash_test.go index 3300af7a2a..0c367ac1b9 100644 --- a/internal/tui3/homeslash_test.go +++ b/internal/tui3/homeslash_test.go @@ -510,7 +510,7 @@ func TestAltWCyclesWhereTheNextConversationOpens(t *testing.T) { runCmd(a.key(key("alt+p"))) } else { homeText(a) - if _, took := a.placeTargetPress(a.targetFolderSpan.from, a.targetRow); !took { + if _, took := a.placeTargetPress(a.targetFolderSpan.from, a.footRow); !took { t.Fatal("the seam project did not accept the click") } } diff --git a/internal/tui3/hometip.go b/internal/tui3/hometip.go index 1edce7308b..8db2eb8e26 100644 --- a/internal/tui3/hometip.go +++ b/internal/tui3/hometip.go @@ -8,9 +8,9 @@ import ( "github.com/Agent-Field/codeaf/internal/tui2/tokens" ) -// ── HOME'S TWO LOWEST ROWS, LAID OUT ───────────────────────────────────────── +// ── THE TIP ROW, AND HOME'S KEYS ROW ───────────────────────────────────────── // -// The foot of home is three rows: the tip, the rule, the keys. +// The foot of either box is three rows: the tip, the rule, the keys. // // 💡 /ask answers right here without opening a conversation ✕ // ─ glm-5.3-flash:auto · ◇ asks ─────────────────────────────────────────────────────────────── @@ -20,26 +20,29 @@ import ( // THE TIP IS RIGHT-ALIGNED OVER THE RULE, one cell in from the edge, directly // above where the rule used to say the project (the owner's placing, // 2026-09-22). It is led by a bulb and closed by a cross a pointer can press: -// the cross puts the tip away until home is next visited or its own clock -// brings the next one round ([app.noticeHomeDismiss]). +// the cross puts the tip away until the row next changes hands +// ([app.noticeDismiss]). The same row, laid out by the same function, stands +// over a conversation's box (view.go's [app.chrome]) once the person has been +// quiet there for a minute (notice.go's THE CONVERSATION'S CLOCK). // // THE PROJECT IS ON THE KEYS ROW NOW, right-justified, and it is the keys that // keep their room: the path gives up its right end, one ellipsis, where the // keys leave it no room for the whole, and goes entirely where they leave it -// less than a word. It is still the door onto the folder chooser it was on the -// rule (placemouse.go's [app.placeTargetPress]), so its columns are recorded -// where they are drawn, on [app.homeDoor]'s bargain. +// less than a word. On home it is still the door onto the folder chooser it +// was on the rule (placemouse.go's [app.placeTargetPress]), so its columns are +// recorded where they are drawn, on [app.homeDoor]'s bargain; a conversation's +// keys row does the same for its own workspace (footswap.go's [app.hintRow]). -// homeTipLead is the bulb before a tip on home's row. +// homeTipLead is the bulb before a tip on the row. // // IT IS AN EMOJI, AND THAT IS THE OWNER'S RULING (2026-09-22) against the // vocabulary's own no-emoji-in-chrome law (internal/tui2/tokens's glyph.go): -// one bulb, on one row, on one place, asked for by name. It is not a slot in -// the vocabulary because the vocabulary refuses the emoji planes on purpose -// and its width gate would refuse this one; and it is not the icon law's to -// own, because the law owns the vocabulary's runes and no other. It measures -// two cells everywhere the renderer measures, and the row is laid out from -// that measurement rather than from a guess. +// one bulb, on one row, asked for by name. It is not a slot in the vocabulary +// because the vocabulary refuses the emoji planes on purpose and its width +// gate would refuse this one; and it is not the icon law's to own, because the +// law owns the vocabulary's runes and no other. It measures two cells +// everywhere the renderer measures, and the row is laid out from that +// measurement rather than from a guess. const homeTipLead = "💡" // homeTipGap is the cell between the bulb and the tip, and between the tip and @@ -52,14 +55,14 @@ const homeTipGap = " " const homeTipFloor = 8 // homeFootPathFloor is the fewest cells of path worth drawing after -// `project: ` on the keys row — the root and an ellipsis, or nothing. +// `project: ` on a keys row — the root and an ellipsis, or nothing. const homeFootPathFloor = 4 -// homeTipLine lays the tip row out: the bulb, the tip, the cross, right-aligned +// tipLine lays the tip row out: the bulb, the tip, the cross, right-aligned // to end one cell in from the right edge. It reports the cross's columns, for // the press, and an empty line where the frame is too narrow for the row to // say anything. -func (a *app) homeTipLine(tip string, width int, pal palette) (string, hudSpan) { +func (a *app) tipLine(tip string, width int, pal palette) (string, hudSpan) { cross := pal.glyph(tokens.GFailed) lead := homeTipLead + homeTipGap tail := homeTipGap + cross @@ -88,25 +91,29 @@ func (a *app) homeFootLine(width int, pal palette) string { return line } used := 1 + ansi.StringWidth(hint) - room := width - 1 - used - hudGap - lead := targetProjectLead - if room < ansi.StringWidth(lead)+homeFootPathFloor { + text, span, ok := projectAtRight(project, used, width) + if !ok { return line } - path := fit(project, room-ansi.StringWidth(lead)) - text := lead + path + a.targetFolderSpan = span pad := width - 1 - used - ansi.StringWidth(text) - from := used + pad + ansi.StringWidth(lead) - a.targetFolderSpan = hudSpan{from: from, to: from + ansi.StringWidth(path)} - painted := a.paintSeamProject(text, hudSpan{from: ansi.StringWidth(lead), to: ansi.StringWidth(text)}, + painted := a.paintSeamProject(text, hudSpan{from: ansi.StringWidth(targetProjectLead), to: ansi.StringWidth(text)}, a.targetHover == hoverSeamProject) return line + strings.Repeat(" ", pad) + painted } -// noticeHomeDismiss is the cross on home's tip row: the tip goes away until -// the next visit to home or the next turn of its own clock, and nothing is -// written down — a tip put away is not a tip learned, so it is not retired. -func (a *app) noticeHomeDismiss() { - a.notices.homeHidden = true - a.touch() +// projectAtRight is the arithmetic both keys rows share: the project after +// `project: `, fitted to what the keys leave and ending one cell in from the +// right edge, with the path's columns on the row. It answers false where the +// keys leave less than a word of path. +func projectAtRight(project string, used, width int) (text string, span hudSpan, ok bool) { + lead := targetProjectLead + room := width - 1 - used - hudGap + if room < ansi.StringWidth(lead)+homeFootPathFloor { + return "", hudSpan{}, false + } + path := fit(project, room-ansi.StringWidth(lead)) + text = lead + path + from := width - 1 - ansi.StringWidth(path) + return text, hudSpan{from: from, to: from + ansi.StringWidth(path)}, true } diff --git a/internal/tui3/hometip_test.go b/internal/tui3/hometip_test.go index 5468d25b7c..110f4dca46 100644 --- a/internal/tui3/hometip_test.go +++ b/internal/tui3/hometip_test.go @@ -13,7 +13,7 @@ import ( // The conversation's foot has carried earned hints since notice.go was written; // home, the other box a person types into, said nothing. These pin the row // above home's rule: it says a tip over an idle box, says nothing while the box -// is being typed into, moves on every visit and every [homeHintEvery] at rest, +// is being typed into, moves on every visit and every [hintEvery] at rest, // and a tip spent on either box is spent on both. // The frame's row directly above the rule is the tip, and only over an empty @@ -33,7 +33,7 @@ func TestHomeRowSaysATipOverAnEmptyBox(t *testing.T) { t.Fatalf("the tip is not on the frame:\n%s", homeText(a)) } if !strings.HasPrefix(tip, "/") && !strings.HasPrefix(tip, "ctrl+") && !strings.HasPrefix(tip, "alt+") && - !strings.HasPrefix(tip, "opt+") && !strings.HasPrefix(tip, "@") && !strings.HasPrefix(tip, "ask ") { + !strings.HasPrefix(tip, "opt+") && !strings.HasPrefix(tip, "@") && !strings.HasPrefix(tip, "enter ") { t.Fatalf("the tip does not open with the key or the command: %q", tip) } @@ -58,7 +58,7 @@ func TestHomeRowSaysATipOverAnEmptyBox(t *testing.T) { } a.key(key("backspace")) - // OFF IS OFF. The Display tab's row silences this slot with the other. + // OFF IS OFF. The Workspace tab's row silences this slot with the other. a.notices.enabled = false if got := a.noticeHomeHint(); got != "" { t.Fatalf("a silenced profile still says %q on home", got) @@ -69,7 +69,7 @@ func TestHomeRowSaysATipOverAnEmptyBox(t *testing.T) { } // Every road home moves the row on; so does the beat once a tip has stood -// [homeHintEvery] at rest — and neither moves it while the box is being typed +// [hintEvery] at rest — and neither moves it while the box is being typed // into, because a tip nobody could read has not been shown. func TestHomeRowMovesOnEveryVisitAndAtRest(t *testing.T) { lab := newHomeLab(t) @@ -89,7 +89,7 @@ func TestHomeRowMovesOnEveryVisitAndAtRest(t *testing.T) { } // AT REST THE BEAT MOVES IT, but not before its time. - now = now.Add(homeHintEvery - time.Second) + now = now.Add(hintEvery - time.Second) a.noticeHomeBeat() if got := a.notices.current[slotHome]; got != second { t.Fatalf("the beat moved the row early, to %q", got) @@ -103,7 +103,7 @@ func TestHomeRowMovesOnEveryVisitAndAtRest(t *testing.T) { // A BOX BEING TYPED INTO DOES NOT AGE THE ROW. a.key(key("x")) - now = now.Add(2 * homeHintEvery) + now = now.Add(2 * hintEvery) a.noticeHomeBeat() if got := a.notices.current[slotHome]; got != third { t.Fatalf("the beat moved the row under a typed box, to %q", got) @@ -133,7 +133,7 @@ func TestHomeRowMovesOnEveryVisitAndAtRest(t *testing.T) { } } -// A tip retired from home is retired from the conversation's foot as well, and +// A tip retired from home is retired from the conversation's row as well, and // the row moves on at once rather than standing empty. func TestATipSpentOnHomeIsSpentEverywhere(t *testing.T) { lab := newHomeLab(t) @@ -170,58 +170,60 @@ func TestATipSpentOnHomeIsSpentEverywhere(t *testing.T) { } } -// Home counts every turn of its rotation as a showing, and a tip that has come -// round [homeShownDefault] times is taken as read. -func TestHomeCountsEveryTurnOfItsRotation(t *testing.T) { - b := bareNoticeBoard() - cands := []noticeCandidate{{id: "a", armed: true}, {id: "b", armed: true}} - turns := map[string]int{} - for i := 0; i < 2*homeShownDefault; i++ { - b.homeAdvance = true - id := b.pick(slotHome, cands, 0) - if id == "" { - t.Fatalf("turn %d put nothing on the row", i) +// Every turn of a row's rotation is a showing, on either box, and a tip that +// has come round [noticeShownDefault] times is taken as read. +func TestEveryTurnOfTheRotationIsAShowing(t *testing.T) { + for _, slot := range []noticeSlot{slotHome, slotHint} { + b := bareNoticeBoard() + cands := []noticeCandidate{{id: "a", armed: true}, {id: "b", armed: true}} + turns := map[string]int{} + for i := 0; i < 2*noticeShownDefault; i++ { + b.advance[slot] = true + id := b.pick(slot, cands) + if id == "" { + t.Fatalf("turn %d put nothing on the row", i) + } + b.take(slot, id, noticeShownDefault, true) + turns[id]++ + } + if turns["a"] != noticeShownDefault || turns["b"] != noticeShownDefault { + t.Fatalf("the ring did not share the turns evenly: %v", turns) + } + if !b.retired("a") || !b.retired("b") { + t.Fatalf("after %d turns each the tips are not retired: %+v", noticeShownDefault, b.ledger) + } + b.advance[slot] = true + if got := b.pick(slot, cands); got != "" { + t.Fatalf("a retired tip came back: %q", got) } - b.take(slotHome, id, homeShownDefault, 0) - turns[id]++ - } - if turns["a"] != homeShownDefault || turns["b"] != homeShownDefault { - t.Fatalf("the ring did not share the turns evenly: %v", turns) - } - if !b.retired("a") || !b.retired("b") { - t.Fatalf("after %d turns each the tips are not retired: %+v", homeShownDefault, b.ledger) - } - b.homeAdvance = true - if got := b.pick(slotHome, cands, 0); got != "" { - t.Fatalf("a retired tip came back: %q", got) } } // A tip that stops being eligible stands down at once and the next takes over, // without waiting for a visit — and an event between visits otherwise leaves // the row alone. -func TestHomeRowHoldsBetweenVisitsAndYieldsWhenSpent(t *testing.T) { +func TestARowHoldsBetweenVisitsAndYieldsWhenSpent(t *testing.T) { b := bareNoticeBoard() cands := []noticeCandidate{{id: "a", armed: true}, {id: "b", armed: true}, {id: "c", armed: true}} - b.homeAdvance = true - if got := b.pick(slotHome, cands, 0); got != "a" { + b.advance[slotHome] = true + if got := b.pick(slotHome, cands); got != "a" { t.Fatalf("the ring did not start at the top: %q", got) } - b.take(slotHome, "a", homeShownDefault, 0) + b.take(slotHome, "a", noticeShownDefault, true) // An event with nothing advancing keeps the one standing. - if got := b.pick(slotHome, cands, 0); got != "a" { + if got := b.pick(slotHome, cands); got != "a" { t.Fatalf("an event moved the row without a visit, to %q", got) } // The one standing retiring hands the row to the next in the ring. b.retire("a") - if got := b.pick(slotHome, cands, 0); got != "b" { + if got := b.pick(slotHome, cands); got != "b" { t.Fatalf("a spent tip did not yield to the next: %q", got) } // And with nothing eligible the row is empty rather than stale. for _, c := range cands { b.retire(c.id) } - if got := b.pick(slotHome, cands, 0); got != "" { + if got := b.pick(slotHome, cands); got != "" { t.Fatalf("an empty ring still says %q", got) } } @@ -240,37 +242,28 @@ func TestEveryTipIsOnTheManualPage(t *testing.T) { } } -// The cut was thirty, and the row above home's rule draws only rows that name -// it: a note row that names a box, or a row filed under home's slot, does not -// build. -func TestTheTableIsThirtyHintsAndEachNamesItsBoxes(t *testing.T) { - hints, home, chat := 0, 0, 0 +// The cut was thirty, and there is ONE set: every hint draws on both boxes, +// a news row on neither, and a row filed under home's slot does not build. +func TestTheTableIsThirtyHintsAndEveryOneDrawsOnBothBoxes(t *testing.T) { + hints := 0 for _, n := range notices { if n.slot != slotHint { continue } hints++ - if n.draws(slotHome) { - home++ + if !n.draws(slotHome) || !n.draws(slotHint) { + t.Errorf("hint %q does not draw on both boxes", n.id) } - if n.draws(slotHint) { - chat++ + if n.draws(slotNote) { + t.Errorf("hint %q draws in the transcript", n.id) } } if hints != 30 { t.Fatalf("the table holds %d hints, want 30 — the cut is deliberate, and the manual page counts them", hints) } - if home == 0 || chat == 0 { - t.Fatalf("%d hints draw on home and %d in a conversation; both boxes need some", home, chat) - } - // A hint that names no box is the conversation's, which is what every row - // meant before home had a row. - plain := notice{id: "boxless", slot: slotHint, armed: ready, text: "x"} - if !plain.draws(slotHint) || plain.draws(slotHome) { - t.Fatal("a hint naming no box is not the conversation's alone") - } - if err := checkNotices([]notice{{id: "noted", slot: slotNote, place: onHome, armed: ready, text: "x"}}); err == nil { - t.Fatal("a note naming a box was accepted") + news := notice{id: "noted", slot: slotNote, armed: ready, text: "x"} + if news.draws(slotHint) || news.draws(slotHome) || !news.draws(slotNote) { + t.Fatal("a news row draws beside a box") } if err := checkNotices([]notice{{id: "filed", slot: slotHome, armed: ready, text: "x"}}); err == nil { t.Fatal("a row filed under home's slot was accepted") diff --git a/internal/tui3/hometiplayout_test.go b/internal/tui3/hometiplayout_test.go index d7c82869f9..1485e4d527 100644 --- a/internal/tui3/hometiplayout_test.go +++ b/internal/tui3/hometiplayout_test.go @@ -85,11 +85,11 @@ func TestHomeTipRowSaysNothingOnAFrameTooNarrowForIt(t *testing.T) { lab := newHomeLab(t) a := lab.door("") a.showPage(pageHome) - line, span := a.homeTipLine("/ask answers right here", 12, a.pal) + line, span := a.tipLine("/ask answers right here", 12, a.pal) if line != "" || span.pressable() { t.Fatalf("a 12-cell frame drew a tip row: %q", line) } - line, span = a.homeTipLine("/ask answers right here without opening a conversation", 40, a.pal) + line, span = a.tipLine("/ask answers right here without opening a conversation", 40, a.pal) if line == "" || !span.pressable() { t.Fatal("a 40-cell frame drew no tip row") } diff --git a/internal/tui3/host_test.go b/internal/tui3/host_test.go index 9a52b7706e..927851c181 100644 --- a/internal/tui3/host_test.go +++ b/internal/tui3/host_test.go @@ -73,8 +73,13 @@ func TestTheLegendNamesTheMachineAsItsOwnSegment(t *testing.T) { if strings.Contains(line, "devbox:vendor/model") { t.Fatalf("the machine is spelled with the path's colon: %q", line) } - if !strings.Contains(line, "project: devbox:/srv/code/app") { - t.Fatalf("the legend lost the remote project path: %q", line) + // The project is on the keys row since 2026-09-22 (footswap.go), and it + // carries the machine there the way the seam did. + if strings.Contains(line, targetProjectLead) { + t.Fatalf("the legend still carries the project: %q", line) + } + if keys := plain(a.hintRow(a.width)); !strings.Contains(keys, "project: devbox:/srv/code/app") { + t.Fatalf("the keys row lost the remote project path: %q", keys) } // AND THE MACHINE IS NAMED ONCE. An unnamed conversation draws the same // line: nothing stands in for a name the seam does not carry, and diff --git a/internal/tui3/hover.go b/internal/tui3/hover.go index 5a9f86c053..9ffb5a2236 100644 --- a/internal/tui3/hover.go +++ b/internal/tui3/hover.go @@ -631,7 +631,9 @@ func (a *app) hoverTarget(x, y int) hoverAt { if a.copy.on || a.pick.open { return hoverAt{} } - if a.seamProjectSpan.holds(x) { + // THE PROJECT IS ON THIS ROW ONLY AT THE PHONE TIER; everywhere else + // it is the keys row's (footswap.go's [app.hintRow]), read below. + if !a.seamCarriesTelemetry() && a.seamProjectSpan.holds(x) { return hoverAt{kind: hoverSeamProject} } if a.seamModelSpan.holds(x) { @@ -671,6 +673,11 @@ func (a *app) hoverTarget(x, y int) hoverAt { if width, _ := a.size(); layoutTier(width) == tierPhone { return hoverAt{kind: hoverDeck, index: mark.index} } + // THE PROJECT AT THE ROW'S RIGHT END IS A DOOR (footswap.go's + // [app.hintRow] records it; [app.seamProjectPress] answers it). + if a.seamProjectSpan.holds(x) { + return hoverAt{kind: hoverSeamProject} + } // THE LAST ROW IS THE KEYS and lights nothing: the home door on it // answers through its own reading (home.go's [app.homeDoorPress]), // and the numbers' doors are on the seam (footswap.go). diff --git a/internal/tui3/notice.go b/internal/tui3/notice.go index 2c225403cf..b5a9cc6640 100644 --- a/internal/tui3/notice.go +++ b/internal/tui3/notice.go @@ -9,6 +9,8 @@ import ( "strings" "time" + tea "charm.land/bubbletea/v2" + "github.com/Agent-Field/codeaf/internal/buildinfo" ) @@ -17,18 +19,33 @@ import ( // A surface learns you by what you have already done, and this file is where it // keeps what it has told you. Two kinds of thing live here at launch: // -// - EARNED HINTS. One line in the legend's hint slot — `ctrl+. sees every task -// this project has run` — that fires the first time it is relevant (a task -// just started) and RETIRES FOR GOOD the first time the gesture it teaches is -// used (the task page opened), or after it has been shown in a few separate -// sessions without being acted on. A hint that stays up after you have -// learned the key is a cheatsheet, and a cheatsheet is read once and never -// again (render.go's [app.hintWord] says the same about static keys). +// - EARNED HINTS. One dim line over the box — `ctrl+. sees every task this +// project has run` — drawn on the row directly above the rule over home's +// box, and on the same row above a conversation's box once the person has +// been idle there for a minute. A tip RETIRES FOR GOOD the first time the +// gesture it teaches is used (the task page opened), or after it has been +// shown [noticeShownDefault] times without being acted on. A hint that stays +// up after you have learned the key is a cheatsheet, and a cheatsheet is +// read once and never again (render.go's [app.hintWord] says the same about +// static keys). // - NEWS. One dim transcript line, said once, the first time this binary runs // after its build changed — the place a shipped feature announces itself. // The channel exists and is empty; a wave that ships something registers a // row with [notice.news] set and writes nothing else. // +// ONE TABLE, TWO BOXES. Until 2026-09-22 a hint row named which box it could +// draw beside and the conversation's foot ranked its rows by a priority number +// while home's row took turns. The owner ruled that there is ONE set of tips +// and that both boxes say them the same way: in the table's order, round and +// round, every tip that is true getting its turn — with one exception, that a +// tip which has JUST become true jumps the ring, so `/compact summarizes the +// conversation now` is said when the window crosses half and not forty minutes +// later ([noticeBoard.pick]). The two boxes keep two clocks, because home has +// no turns and a conversation has no visits: home's row moves on every visit +// and every [hintEvery] at rest; a conversation's row appears only once the +// person has been idle for [chatHintIdle], and then moves on every [hintEvery] +// while they stay idle ([app.noticeIdleBeat]). +// // THE TABLE BELOW IS THE ONE PLACE A NOTICE IS WRITTEN DOWN, the way commands.go // is the one place a command is. [checkNotices] runs over it at init and fails // the build on a duplicate id, an empty line, a retire event nobody defined, or @@ -45,52 +62,37 @@ import ( // need no frame. // // WHAT IS REMEMBERED IS PER PROFILE, in one small file beside config.json -// (notice_ledger.go): how many sessions each notice has been shown in, when it +// (notice_ledger.go): how many times each notice has been shown, when it // retired, and which build the news channel last saw. A missing or unreadable // ledger is an empty one — a person is never told their hints file is corrupt, // because the worst case is a tip they have seen before. -// noticeSlot is where a notice may draw. Exactly two exist; the type is an enum -// rather than a bool so a later wave can add one without touching the rows that -// exist — a new slot lands as one constant above [noticeSlots] and one case in +// noticeSlot is where a notice may draw. The type is an enum rather than a bool +// so a later wave can add one without touching the rows that exist — a new +// slot lands as one constant above [noticeSlots] and one case in // [app.noticeShow]. type noticeSlot uint8 const ( - // slotHint is the legend's hint slot (render.go's [app.legendRight]), and a - // notice standing in it is the LOWEST RUNG THERE IS: every state key and every - // existing hint outranks it, so a tip is only ever drawn over an idle box. + // slotHint is the row directly above the rule over a conversation's box + // (view.go's [app.chrome] draws it on the foot's clearance), drawn only + // once the person has been idle for [chatHintIdle] and the frame is quiet + // enough for a tip to be read over an idle box ([app.noticeHint]). slotHint noticeSlot = iota // slotNote is one calm transcript line through [feed.note]. It is reserved // for news: a hint belongs beside the box it is about, and a line in the // conversation is for something that is true once. slotNote - // slotHome is the dim row directly above the rule over home's box - // (pages.go's [placeFrameWithBar]), and it is the hint slot's twin on the - // other box a person types into: the same table, the same ledger, the same - // retirement — and a different clock, because home has no turns. The rows - // that may stand in it are the hint rows whose [notice.place] says so, so a - // tip retired by its gesture is retired on both boxes at once. + // slotHome is the same row over home's box (pages.go's [placeFrameWithBar]), + // and it is the hint slot's twin on the other box a person types into: the + // same table, the same ledger, the same retirement — and a different clock, + // because home has no turns. Every hint row draws in both, so a tip retired + // by its gesture is retired on both boxes at once. slotHome // noticeSlots is how many there are. A new slot goes above this line. noticeSlots ) -// hintPlace is WHERE a hint row may draw: the conversation's foot, home's row, -// or both. It is a set rather than a second slot on the row because one tip is -// one promise — `/model lists every model` is as true on home as it is in a -// conversation, and a person who opened the picker from either has learned it. -type hintPlace uint8 - -const ( - // inChat is the conversation's foot ([slotHint]). - inChat hintPlace = 1 << iota - // onHome is the row above home's rule ([slotHome]). - onHome - // everywhere is both. - everywhere = inChat | onHome -) - // The events that prove a gesture happened. They are named constants beside the // table so a retire rule cannot be spelled with a typo and silently never // fire: [checkNotices] refuses a rule naming an event that is not in @@ -183,9 +185,6 @@ const ( eventSubharnessOpened = "subharness-opened" // eventConnectOpened is the connect panel reached for (connectpanel.go). eventConnectOpened = "connect-opened" - // eventMediaAsked is the session beginning a picture, sound, music or video - // call — proof the person knows to ask (app.go's event seam). - eventMediaAsked = "media-asked" ) // noticeEvents is every event there is, in one list, so the table check can @@ -199,14 +198,7 @@ var noticeEvents = []string{ eventFolderPicked, eventModelListOpened, eventCrewShown, eventBudgetShown, eventSpendOpened, eventSteered, eventQueued, eventChatStarted, eventPlaceJumped, eventRemembered, eventSearchOpened, eventSubharnessOpened, - eventConnectOpened, eventMediaAsked, -} - -// mediaTools is every tool whose call proves a person asked for a picture, a -// voice, music or film; the belt's own names (internal/session). -var mediaTools = map[string]bool{ - "generate_image": true, "generate_video": true, "generate_music": true, - "speak": true, "edit_video": true, + eventConnectOpened, } // notice is one thing the surface may tell a person, and the whole of the rule @@ -217,16 +209,6 @@ type notice struct { // a person has already been told this and does not want to be again. id string slot noticeSlot - // place is where a [slotHint] row may draw — the conversation's foot, home's - // row, or both. The zero value is the conversation's foot, which is what - // every row meant before home had a row; a news row leaves it zero. Home's - // row takes the rows that name it in the table's order, round and round - // ([noticeBoard.pick]). - place hintPlace - // priority decides between two notices eligible for the conversation's - // slot at once; higher wins, and the table's order breaks a tie. Home's row - // ignores it: there, every eligible tip has its turn. - priority int // armed says whether the notice is relevant right now. It is asked at every // event and never between them, so it must be cheap and must read only what // the surface already holds — a hint whose arming fact would need a counter @@ -242,39 +224,35 @@ type notice struct { // ages out. retire string // maxShown is how many showings the notice gets before it retires by - // itself, whether or not the gesture was ever used; zero means the default - // for where it draws — [noticeShownDefault] in a conversation, where a - // showing is a session, and [homeShownDefault] for a row home takes, where - // a showing is one turn of home's rotation. A hint standing in a slot for - // an hour is one showing either way. + // itself, whether or not the gesture was ever used; zero means + // [noticeShownDefault]. A showing is one turn of a row's rotation, on + // either box: a tip standing on home for an hour is one showing. maxShown int // news marks the what's-new channel: a row that is armed only on the first // launch after the binary's build changed, and shown once. news bool } -// noticeShownDefault is how many sessions a hint may be shown in before it is -// taken as read. Three is one more than a coincidence: a tip seen in two -// separate sessions and never acted on is a tip about something the person -// does not want, and the fourth showing would be the surface nagging. -const noticeShownDefault = 3 - -// homeShownDefault is how many turns of home's rotation a tip may take before -// it is taken as read. It is twice the conversation's figure because home's -// showings are shorter and more frequent: the row changes on every visit and -// every couple of minutes at rest, so six showings is still one afternoon. -const homeShownDefault = 6 +// noticeShownDefault is how many showings a hint gets before it is taken as +// read. Six turns of a rotation, on either box, is one afternoon of a tip +// coming round: a tip seen that often and never acted on is a tip about +// something the person does not want, and the seventh showing would be the +// surface nagging. +const noticeShownDefault = 6 -// homeHintEvery is how long a tip stands on home's row before the next one -// takes it, while home is left at rest. Two minutes is long enough to be read -// and short enough that a home left open over lunch has said a few things. -const homeHintEvery = 2 * time.Minute +// hintEvery is how long a tip stands on a row before the next one takes it, +// while the row is left at rest: home at rest, or a conversation the person +// has gone quiet in. Two minutes is long enough to be read and short enough +// that a window left open over lunch has said a few things. +const hintEvery = 2 * time.Minute -// noticeGap is the fewest turns between one hint standing down and a different -// one taking the slot. It is what keeps a busy first session from reading as a -// slideshow: three hints arming in three consecutive turns are shown one at a -// time, each with room to be read. -const noticeGap = 2 +// chatHintIdle is how long a conversation has to have been left alone — +// no key pressed, no turn ending — before its row says a tip at all. A +// conversation is where the work is, and a sentence appearing over the box +// while somebody is typing or reading an answer that has just landed is the +// surface talking over them; a minute of nothing is the moment they are +// looking around. +const chatHintIdle = time.Minute // The arming thresholds, each named once so the manual page and the table // cannot drift apart about when a hint appears. @@ -305,15 +283,16 @@ var ( // steering or queueing over an answer means nothing before one has arrived. spoken = func(a *app) bool { return a.turn >= 1 } // askable is home's own door standing — the errand builder a launch may or - // may not hand the surface (homeexchange.go's [app.askHereWith]). - askable = func(a *app) bool { return a.errand != nil } + // may not hand the surface (homeexchange.go's [app.askHereWith]) — and the + // person standing on home, where the sentence it arms is true. + askable = func(a *app) bool { return a.errand != nil && a.at(pageHome) } ) -// notices is the table, in priority order for reading. Text is chosen to agree -// with the manual page that answers each hint (internal/manual/chat's -// hints-and-tips.md), so the tip and the page say the same words — and -// notice_test.go holds the page to every line here, so the table cannot say a -// thing the manual does not. +// notices is the table, and ITS ORDER IS THE ORDER THE ROWS COME ROUND IN on +// both boxes ([noticeBoard.pick]). Text is chosen to agree with the manual page +// that answers each hint (internal/manual/chat's hints-and-tips.md), so the tip +// and the page say the same words — and notice_test.go holds the page to every +// line here, so the table cannot say a thing the manual does not. // // THIRTY ROWS, AND THE CUT WAS DELIBERATE. A survey of the surface on // 2026-09-21 turned up forty-eight lines worth saying; these are the thirty @@ -324,7 +303,7 @@ var ( var notices = []notice{ // ── the seven that were here first ────────────────────────────────────── { - id: "compact-at-half", slot: slotHint, place: everywhere, priority: 90, + id: "compact-at-half", slot: slotHint, armed: func(a *app) bool { pct, ok := a.ctxPercent() return ok && pct >= contextHintPct @@ -333,25 +312,25 @@ var notices = []notice{ retire: eventCompacted, }, { - id: "cost-after-spend", slot: slotHint, place: everywhere, priority: 85, + id: "cost-after-spend", slot: slotHint, armed: func(a *app) bool { return a.cost >= costHintUSD }, text: "/cost says what this conversation has spent", retire: eventCostShown, }, { - id: "task-page-after-first-task", slot: slotHint, place: everywhere, priority: 80, + id: "task-page-after-first-task", slot: slotHint, armed: func(a *app) bool { return a.notices.seen[eventTaskStarted] }, text: "ctrl+. sees every task this project has run", retire: eventTaskPageOpened, }, { - id: "rewind-after-long-answer", slot: slotHint, place: everywhere, priority: 70, + id: "rewind-after-long-answer", slot: slotHint, armed: func(a *app) bool { return a.lastAnswerRunes() >= longAnswerRunes }, text: "/rewind takes back an earlier message", retire: eventRewound, }, { - id: "files-after-first-deliverable", slot: slotHint, place: everywhere, priority: 60, + id: "files-after-first-deliverable", slot: slotHint, armed: func(a *app) bool { return a.notices.seen[eventDeliverableMade] }, text: "/files finds everything made for you", retire: eventFilesOpened, @@ -360,160 +339,164 @@ var notices = []notice{ // The welcome box already walked this directory for its recent column // (welcome.go), so the fact is at hand for nothing; a fresh directory // with no earlier conversation has an empty list and the hint stays down. - id: "resume-when-earlier-exists", slot: slotHint, place: everywhere, priority: 40, + id: "resume-when-earlier-exists", slot: slotHint, armed: func(a *app) bool { return len(a.welcome.recent) > 0 }, text: "/resume opens an earlier conversation", retire: eventResumeOpened, }, { - id: "standing-after-several-sessions", slot: slotHint, place: everywhere, priority: 10, + id: "standing-after-several-sessions", slot: slotHint, armed: func(a *app) bool { return len(a.welcome.recent) >= 3 }, text: "/standing keeps something always true", retire: eventStandingOpened, }, // ── starting work ─────────────────────────────────────────────────────── { - id: "ask-on-home", slot: slotHint, place: onHome, + id: "ask-on-home", slot: slotHint, armed: askable, text: "/ask answers right here without opening a conversation", retire: eventAsked, }, { - id: "task-in-chat", slot: slotHint, place: inChat, priority: 55, + id: "task-in-chat", slot: slotHint, armed: spoken, text: "/task starts work you can walk away from", retire: eventTaskTyped, }, { - id: "standing-by-chord", slot: slotHint, place: everywhere, priority: 20, + id: "standing-by-chord", slot: slotHint, armed: ready, text: "ctrl+enter sends your message as something to keep true", retire: eventStandingOpened, }, { - id: "manual-answers", slot: slotHint, place: everywhere, priority: 26, + id: "manual-answers", slot: slotHint, armed: ready, text: "/manual answers any question about codeaf from its own manual", retire: eventManualAsked, }, { - id: "reopen-tab", slot: slotHint, place: everywhere, priority: 17, + id: "reopen-tab", slot: slotHint, armed: ready, text: "ctrl+shift+t reopens the tab you just closed", retire: eventTabReopened, }, // ── files and context ─────────────────────────────────────────────────── { - id: "at-completion", slot: slotHint, place: everywhere, priority: 28, + id: "at-completion", slot: slotHint, armed: ready, text: "@ completes a file, a folder or a task into your message", retire: eventAtOpened, }, { - id: "attach-a-file", slot: slotHint, place: everywhere, priority: 24, + id: "attach-a-file", slot: slotHint, armed: ready, text: "/attach sends a file along with your message", retire: eventAttached, }, { - id: "pick-a-folder", slot: slotHint, place: everywhere, priority: 22, + id: "pick-a-folder", slot: slotHint, armed: ready, text: "/folder picks the folder codeaf works in", retire: eventFolderPicked, }, { - id: "attach-a-picture", slot: slotHint, place: everywhere, priority: 6, + id: "attach-a-picture", slot: slotHint, armed: ready, text: "/attach takes a picture too, or paste a screenshot in", retire: eventAttached, }, { - id: "export-the-conversation", slot: slotHint, place: inChat, priority: 30, + id: "export-the-conversation", slot: slotHint, armed: func(a *app) bool { return a.turn >= 2 }, text: "/export writes this whole conversation to a file", retire: eventDeliverableMade, }, // ── models, thinking and cost ─────────────────────────────────────────── { - id: "model-list", slot: slotHint, place: everywhere, priority: 25, + id: "model-list", slot: slotHint, armed: ready, text: "/model lists every model, /model switches at once", retire: eventModelListOpened, }, { - id: "crew-presets", slot: slotHint, place: everywhere, priority: 13, + id: "crew-presets", slot: slotHint, armed: ready, text: "/crew sets the models codeaf uses on its own behalf", retire: eventCrewShown, }, { - id: "budget-cap", slot: slotHint, place: everywhere, priority: 14, + id: "budget-cap", slot: slotHint, armed: ready, text: "/budget caps what today may cost", retire: eventBudgetShown, }, { - id: "spend-place", slot: slotHint, place: everywhere, priority: 15, + id: "spend-place", slot: slotHint, armed: ready, text: "alt+3 shows what this machine has spent, by the day", retire: eventSpendOpened, }, // ── steering a running answer ─────────────────────────────────────────── { - id: "steer-with-enter", slot: slotHint, place: inChat, priority: 45, + id: "steer-with-enter", slot: slotHint, armed: spoken, text: "enter while an answer is coming stops it and steers", retire: eventSteered, }, { - id: "queue-with-ctrl-q", slot: slotHint, place: inChat, priority: 35, + id: "queue-with-ctrl-q", slot: slotHint, armed: spoken, text: "ctrl+q queues this message for after the current turn", retire: eventQueued, }, // ── moving around ─────────────────────────────────────────────────────── { - id: "new-chat", slot: slotHint, place: everywhere, priority: 18, + id: "new-chat", slot: slotHint, armed: ready, text: "ctrl+t starts a fresh chat in this folder", retire: eventChatStarted, }, { - id: "place-chords", slot: slotHint, place: everywhere, priority: 16, + id: "place-chords", slot: slotHint, armed: ready, text: "alt+1 to alt+7 jump straight to a place", retire: eventPlaceJumped, }, // ── memory, accounts and the rest ─────────────────────────────────────── { - id: "remember-one-thing", slot: slotHint, place: everywhere, priority: 12, + id: "remember-one-thing", slot: slotHint, armed: ready, text: "/remember keeps one thing across conversations", retire: eventRemembered, }, { - id: "search-place", slot: slotHint, place: everywhere, priority: 11, + id: "search-place", slot: slotHint, armed: ready, text: "/search finds anything ever said on this machine", retire: eventSearchOpened, }, { - id: "subharness-list", slot: slotHint, place: everywhere, priority: 9, + id: "subharness-list", slot: slotHint, armed: ready, text: "/subharness lists the programs you can run", retire: eventSubharnessOpened, }, { - id: "connect-accounts", slot: slotHint, place: everywhere, priority: 8, + id: "connect-accounts", slot: slotHint, armed: ready, text: "/connect links Google, Slack or another model service", retire: eventConnectOpened, }, { - id: "ask-for-media", slot: slotHint, place: everywhere, priority: 7, - armed: ready, - text: "ask for a picture, a voiceover, music or a video", - retire: eventMediaAsked, + // It took the seat `ask for a picture, a voiceover, music or a video` + // held until 2026-09-22 (the owner's call): copy mode is the one door + // on this surface with nothing on screen pointing at it, because the + // alt screen takes the terminal's own selection away (copymode.go). + id: "copy-mode", slot: slotHint, + armed: spoken, + text: "ctrl+b freezes the screen so you can read and copy from it", + retire: eventCopyEntered, }, } @@ -549,10 +532,8 @@ func checkNotices(list []notice) error { return fmt.Errorf("notice %q retires on %q, which nothing fires", n.id, n.retire) case n.maxShown < 0: return fmt.Errorf("notice %q has a negative showing limit", n.id) - case n.slot != slotHint && n.place != 0: - return fmt.Errorf("notice %q names a box to draw beside but is not a hint", n.id) case n.slot == slotHome: - return fmt.Errorf("notice %q is filed under home's slot; a hint names home through its place instead", n.id) + return fmt.Errorf("notice %q is filed under home's slot; a hint draws on home by being a hint", n.id) } seen[n.id] = true for _, word := range noticeBanned { @@ -573,25 +554,19 @@ func init() { } } -// limit is the showing limit with the default applied: the row's own figure, -// else home's default for a row home takes, else the conversation's. +// limit is the showing limit with the default applied. func (n notice) limit() int { if n.maxShown > 0 { return n.maxShown } - if n.place&onHome != 0 { - return homeShownDefault - } return noticeShownDefault } -// draws reports whether the row may stand in a slot. +// draws reports whether the row may stand in a slot: a hint row stands in +// both hint slots, and a news row in the note slot. func (n notice) draws(slot noticeSlot) bool { - switch slot { - case slotHint: - return n.slot == slotHint && (n.place == 0 || n.place&inChat != 0) - case slotHome: - return n.slot == slotHint && n.place&onHome != 0 + if n.slot == slotHint { + return slot == slotHint || slot == slotHome } return n.slot == slot } @@ -617,7 +592,8 @@ type noticeBoard struct { // recorded nothing; news is whether the ledger last saw a different one. build string news bool - // enabled is the Display tab's "hints" row. Off silences both slots. + // enabled is the Workspace tab's "disable hints" row, read the other way + // up. Off silences every slot. enabled bool // current is the id standing in each slot, "" for none. current [noticeSlots]string @@ -628,25 +604,33 @@ type noticeBoard struct { // the surface not having noticed. seen map[string]bool done map[string]bool - // shown is every notice counted as shown this session, so an hour in the - // slot is one showing and not one per event. - shown map[string]bool - // lastHintTurn is the turn the hint slot last changed hands on, or -1 when - // it never has; [noticeGap] is measured from it. - lastHintTurn int - // homeAdvance asks the next decision about home's row to move on to the - // next eligible tip rather than keep the one standing. It is raised by - // [app.noticeHomeRotate] — a visit, or the beat at rest — and spent by the - // pick that honours it, so an event between two rotations leaves the row - // alone unless the tip on it has just retired. - homeAdvance bool - // homeAt is when home's row last changed hands, or zero when it never - // has; [homeHintEvery] is measured from it by the beat. - homeAt time.Time - // homeHidden is the cross on the row having been pressed: the tip standing - // is not drawn until the next rotation, which clears it. It is this + // armed is, per slot, whether each row was armed at that slot's last + // decision — what makes a row FRESH at the next one ([noticeCandidate.fresh]). + armed [noticeSlots]map[string]bool + // advance asks the next decision about a slot to move on to the next + // eligible tip rather than keep the one standing. It is raised by + // [app.noticeRotate] — a visit to home, a beat at rest — and spent by the + // pick that honours it, so an event between two rotations leaves a row + // alone unless the tip on it has just retired or a fresh one has arrived. + advance [noticeSlots]bool + // at is when each slot last changed hands, or zero when it never has; + // [hintEvery] is measured from it by the beats. + at [noticeSlots]time.Time + // hidden is the cross on a row having been pressed: the tip standing is + // not drawn until the slot next changes hands, which clears it. It is this // session's and never the ledger's — putting a tip away is not using it. - homeHidden bool + hidden [noticeSlots]bool + // touched is the last proof the person was doing something in a + // conversation — a key pressed, a turn ending — and due is whether they + // have since been quiet for [chatHintIdle], which is what lets the + // conversation's row draw at all ([app.noticeHint]). + touched time.Time + due bool + // idleArmed and idleGen are the conversation's one clock: whether a beat is + // pending, and which arming it belongs to, so a beat from a clock that has + // since been re-armed is dropped ([app.noticeIdleBeat]). + idleArmed bool + idleGen int } // bareNoticeBoard is a board with nothing behind it: no ledger on disk, no @@ -655,25 +639,21 @@ type noticeBoard struct { // nothing — which is why it is reachable from a frame and the loader is not. func bareNoticeBoard() noticeBoard { return noticeBoard{ - enabled: true, - seen: map[string]bool{}, - done: map[string]bool{}, - shown: map[string]bool{}, - lastHintTurn: -1, + enabled: true, + seen: map[string]bool{}, + done: map[string]bool{}, } } // newNoticeBoard loads the ledger and decides whether there is news. func newNoticeBoard(path, build string, enabled bool) noticeBoard { b := noticeBoard{ - ledger: loadNoticeLedger(path), - path: path, - build: build, - enabled: enabled, - seen: map[string]bool{}, - done: map[string]bool{}, - shown: map[string]bool{}, - lastHintTurn: -1, + ledger: loadNoticeLedger(path), + path: path, + build: build, + enabled: enabled, + seen: map[string]bool{}, + done: map[string]bool{}, } // A FIRST LAUNCH HAS NO NEWS. Nothing is new to somebody who has never // seen the older build; the channel opens on the second build a profile @@ -714,72 +694,47 @@ func (b *noticeBoard) retire(id string) { // noticeCandidate is one row as the board sees it at an event: evaluated, so // that [noticeBoard.pick] needs no frame to be tested against. type noticeCandidate struct { - id string - priority int - armed bool - limit int + id string + armed bool + // fresh is a row that is armed now and was not at this slot's last + // decision — the one thing that jumps the ring. + fresh bool } -// pick decides what a slot should hold, given the candidates for it and the -// turn the surface is on. It returns "" for nothing, and it changes nothing on -// the board — [noticeBoard.take] records the decision. +// pick decides what a slot should hold, given the candidates for it. It +// returns "" for nothing, and it changes nothing on the board but the +// [noticeBoard.advance] it spends — [noticeBoard.take] records the decision. // -// The rules, in the order they are applied: +// IT IS A ROTATION AND NOT A RANKING: EVERY ELIGIBLE TIP HAS ITS TURN, in the +// table's order, round and round. The one standing keeps standing until the +// slot is asked to advance — or until it stops being eligible, when the next +// takes over at once so the row is never blank while there is something true +// to say. With one eligible tip the rotation is that tip; with none the row +// is empty. A retired notice, or one retired this session, is never a +// candidate. // -// - A retired notice, or one retired this session, is never a candidate. -// - Among the armed ones the highest priority wins, table order breaking a -// tie. The one already standing is preferred over an equal. -// - THE HINT SLOT CHANGES HANDS SLOWLY. A different id may take it only once -// [noticeGap] turns have passed since it last changed, so three hints arming -// in three turns are read one at a time. The slot's first occupant of the -// session waits on nothing. A slot going EMPTY never waits: a hint whose -// arming fact stopped being true stands down at once. -func (b *noticeBoard) pick(slot noticeSlot, cands []noticeCandidate, turn int) string { +// THE ONE EXCEPTION IS A TIP THAT HAS JUST BECOME TRUE. It jumps the ring +// whether or not the slot was asked to move: `/compact summarizes the +// conversation now` is worth saying when the window crosses half, and a ring +// of twenty tips would otherwise bring it round the best part of an hour +// later. It jumps once — at the decision that first sees it armed — and then +// takes its turn like every other row. +func (b *noticeBoard) pick(slot noticeSlot, cands []noticeCandidate) string { held := b.current[slot] - if slot == slotHome { - return b.pickHome(cands, held) - } - best, found := noticeCandidate{}, false + eligible := func(c noticeCandidate) bool { return c.armed && !b.done[c.id] && !b.retired(c.id) } + advance := b.advance[slot] + b.advance[slot] = false for _, c := range cands { - if !c.armed || b.done[c.id] || b.retired(c.id) { - continue - } - if !found || c.priority > best.priority || (c.priority == best.priority && c.id == held) { - best, found = c, true - } - } - if !found { - return "" - } - if slot == slotHint && best.id != held && b.lastHintTurn >= 0 && turn-b.lastHintTurn < noticeGap { - // Too soon for a different line. The one standing keeps standing if it - // is still eligible, and the slot goes quiet otherwise. - for _, c := range cands { - if c.id == held && c.armed && !b.done[c.id] && !b.retired(c.id) { - return held - } + if c.fresh && c.id != held && eligible(c) { + return c.id } - return "" } - return best.id -} - -// pickHome is [noticeBoard.pick] for home's row, and it is a rotation rather -// than a ranking: EVERY ELIGIBLE TIP HAS ITS TURN, in the table's order, round -// and round. The one standing keeps standing until [homeAdvance] asks for the -// next — or until it stops being eligible, when the next takes over at once -// so the row is never blank while there is something true to say. With one -// eligible tip the rotation is that tip; with none the row is empty. -func (b *noticeBoard) pickHome(cands []noticeCandidate, held string) string { - eligible := func(c noticeCandidate) bool { return c.armed && !b.done[c.id] && b.retired(c.id) == false } at := -1 for i, c := range cands { if c.id == held { at = i } } - advance := b.homeAdvance - b.homeAdvance = false if at >= 0 && !advance && eligible(cands[at]) { return held } @@ -793,36 +748,38 @@ func (b *noticeBoard) pickHome(cands []noticeCandidate, held string) string { return "" } -// take records that a slot now holds id — counting the showing once per -// session, retiring the notice when this showing was its last allowed, and -// noting the turn so the gap can be measured. It reports whether the slot's -// occupant changed, and whether the ledger did. +// take records that a slot now holds id — counting the showing when the row +// is live, and retiring the notice when this showing was its last allowed. It +// reports whether the slot's occupant changed, and whether the ledger did. // -// HOME COUNTS EVERY TURN OF ITS ROTATION AS A SHOWING, where the conversation's -// slot counts a session: a tip that has come round six times on home has been -// read six times, however many launches that took ([homeShownDefault]). -func (b *noticeBoard) take(slot noticeSlot, id string, limit int, turn int) (changed, wrote bool) { +// EVERY VISIBLE CHANGE OF HANDS IS A SHOWING, on either box: a tip that has +// come round six times has been read six times, however many launches or +// visits that took ([noticeShownDefault]). A slot re-decided to the same tip +// is not a showing, which is what keeps an hour of events on one tip at one; +// and a slot deciding while its row cannot be seen — home's while a +// conversation is in front, the conversation's before its quiet minute — is +// not one either, because what has not been read has not been shown +// ([app.noticeLive]). +func (b *noticeBoard) take(slot noticeSlot, id string, limit int, live bool) (changed, wrote bool) { if b.current[slot] == id { return false, false } b.current[slot] = id - if id == "" { + if id == "" || !live { return true, false } - if slot == slotHint { - b.lastHintTurn = turn - } - if slot != slotHome && b.shown[id] { - return true, false - } - b.shown[id] = true - count := b.ledger.show(id) - if count >= limit { + return true, b.count(id, limit) +} + +// count records one showing of id, retiring it when that was its last +// allowed, and reports that the ledger changed. +func (b *noticeBoard) count(id string, limit int) bool { + if b.ledger.show(id) >= limit { // The last allowed showing is still a showing: the line stays up for - // this session and the ledger closes the book on it for the next. + // now and the ledger closes the book on it for the next time. b.ledger.retire(id) } - return true, true + return true } // ── THE SURFACE'S SIDE ────────────────────────────────────────────────────── @@ -865,11 +822,14 @@ func (a *app) noticeEvent(name string) { func (a *app) noticeFill(slot noticeSlot) bool { b := &a.notices if !b.enabled { - // Off is off for both slots: a person who silenced hints did not ask to + // Off is off for every slot: a person who silenced hints did not ask to // be told about features either. The rows are left exactly as they are, // so turning the toggle back on shows what was due. return false } + if b.armed[slot] == nil { + b.armed[slot] = make(map[string]bool, len(notices)) + } cands := make([]noticeCandidate, 0, len(notices)) limits := make(map[string]int, len(notices)) for _, n := range notices { @@ -879,13 +839,18 @@ func (a *app) noticeFill(slot noticeSlot) bool { if n.news && !b.news { continue } - cands = append(cands, noticeCandidate{id: n.id, priority: n.priority, armed: n.armed(a)}) + armed := n.armed(a) + cands = append(cands, noticeCandidate{id: n.id, armed: armed, fresh: armed && !b.armed[slot][n.id]}) + b.armed[slot][n.id] = armed limits[n.id] = n.limit() } - id := b.pick(slot, cands, a.turn) - changed, wrote := b.take(slot, id, limits[id], a.turn) - if changed && slot == slotHome { - b.homeAt = a.now() + id := b.pick(slot, cands) + changed, wrote := b.take(slot, id, limits[id], a.noticeLive(slot)) + if changed { + // A new tip is a new thing to read: the clock starts again and a cross + // pressed over the old one is spent. + b.at[slot] = a.now() + b.hidden[slot] = false } if changed && id != "" { a.noticeShow(slot, id) @@ -893,26 +858,53 @@ func (a *app) noticeFill(slot noticeSlot) bool { return wrote } -// noticeHomeRotate moves home's row on to the next tip: on every visit to home -// ([app.showPage]) and on the beat once a tip has stood [homeHintEvery] at -// rest ([app.noticeHomeBeat]). Rotating is the one thing an event does not do -// to this slot, so it is its own seam. -func (a *app) noticeHomeRotate() { +// noticeLive is whether a slot's row can be seen at all right now — which is +// what makes a change of hands a showing ([noticeBoard.take]): home's row +// while home is in front, the conversation's once its quiet minute has passed. +func (a *app) noticeLive(slot noticeSlot) bool { + switch slot { + case slotHome: + return a.at(pageHome) + case slotHint: + return a.showing() == nil && a.notices.due + } + return true +} + +// noticeLimit is the showing limit of the notice with this id. +func (a *app) noticeLimit(id string) int { + for _, n := range notices { + if n.id == id { + return n.limit() + } + } + return noticeShownDefault +} + +// noticeRotate moves a row on to the next tip. It is asked on every visit to +// home ([app.showPage]), on home's beat once a tip has stood [hintEvery] at +// rest ([app.noticeHomeBeat]), and on the conversation's beat while the person +// stays quiet ([app.noticeIdleBeat]). Rotating is the one thing an event does +// not do to a slot, so it is its own seam. +func (a *app) noticeRotate(slot noticeSlot) { b := &a.notices if b.seen == nil { *b = bareNoticeBoard() } - b.homeAdvance = true - b.homeHidden = false - if a.noticeFill(slotHome) { + b.advance[slot] = true + b.hidden[slot] = false + if a.noticeFill(slot) { b.save() } a.touch() } +// noticeHomeRotate is [app.noticeRotate] for home's row: every road home. +func (a *app) noticeHomeRotate() { a.noticeRotate(slotHome) } + // noticeHomeBeat is home's clock asking whether the row is due to move // (app.go's [homeTickMsg]): it is, once the tip standing has been up for -// [homeHintEvery] while home was quiet enough for it to be read. A row nobody +// [hintEvery] while home was quiet enough for it to be read. A row nobody // could see — the box being typed into, a list up — does not age, because // what has not been read has not been shown. func (a *app) noticeHomeBeat() { @@ -920,7 +912,7 @@ func (a *app) noticeHomeBeat() { if b.current[slotHome] == "" || !a.noticeHomeQuiet() { return } - if a.now().Sub(b.homeAt) >= homeHintEvery { + if a.now().Sub(b.at[slotHome]) >= hintEvery { a.noticeHomeRotate() } } @@ -931,12 +923,18 @@ func (a *app) noticeHomeBeat() { func (a *app) noticeHomeHint() string { b := &a.notices id := b.current[slotHome] - if id == "" || !b.enabled || b.homeHidden || !a.noticeHomeQuiet() { + if id == "" || !b.enabled || b.hidden[slotHome] || !a.noticeHomeQuiet() { return "" } + return a.chords.say(a.noticeLine(id)) +} + +// noticeLine is what the notice with this id says right now, or "" for an id +// the table does not hold. +func (a *app) noticeLine(id string) string { for _, n := range notices { if n.id == id { - return a.chords.say(n.line(a)) + return n.line(a) } } return "" @@ -949,49 +947,139 @@ func (a *app) noticeHomeQuiet() bool { a.paneExchange() == nil && !a.targetPickShowing() && !a.composer.open && !a.hopShowing() } -// noticeShow puts a newly chosen notice where its slot draws. The hint slot is -// read at render time ([app.noticeHint]) and needs nothing done here; the note -// slot is a line in the transcript, said once, now. +// noticeDismiss is the cross on a tip row: the tip goes away until the row +// next changes hands — the next visit to home, the next turn of its clock — +// and nothing is written down, because a tip put away is not a tip learned. +func (a *app) noticeDismiss(slot noticeSlot) { + a.notices.hidden[slot] = true + a.touch() +} + +// noticeShow puts a newly chosen notice where its slot draws. The hint slots +// are read at render time ([app.noticeHint], [app.noticeHomeHint]) and need +// nothing done here; the note slot is a line in the transcript, said once, now. func (a *app) noticeShow(slot noticeSlot, id string) { if slot != slotNote { return } - for _, n := range notices { - if n.id == id { - a.note(n.line(a)) - return - } + if line := a.noticeLine(id); line != "" { + a.note(line) } } -// noticeHint is the hint slot's lowest rung: the line standing in [slotHint], -// while the frame is quiet enough for a tip to be read over an idle box. +// noticeHint is the line standing on the conversation's row: drawn only once +// the person has been quiet for [chatHintIdle] ([noticeBoard.due]), while the +// frame is quiet enough for a tip to be read over an idle box, and not while +// its cross has been pressed. // -// IT DRAWS OVER NOTHING THAT IS HAPPENING. Every state with keys of its own has -// already answered in [app.hintWord] by the time this is asked, and the list -// here is the handful of states that answer "" there on purpose — the rewind -// bar prints its own keys, a fullscreen page has no legend — plus the one this -// slot adds: a box with words in it belongs to the sentence being written. +// IT DRAWS OVER NOTHING THAT IS HAPPENING. A running turn, a list, a layer, a +// box with words in it — each of those belongs to the thing being done, and +// the list here is [app.noticeQuiet]. func (a *app) noticeHint() string { b := &a.notices id := b.current[slotHint] - if id == "" || !b.enabled || !a.noticeQuiet() { + if id == "" || !b.enabled || !b.due || b.hidden[slotHint] || !a.noticeQuiet() { return "" } - for _, n := range notices { - if n.id == id { - return a.chords.say(n.line(a)) - } - } - return "" + return a.chords.say(a.noticeLine(id)) } // noticeQuiet is whether nothing on the frame outranks a tip. func (a *app) noticeQuiet() bool { - return a.input.empty() && a.state != stateWorking && - !a.rew.on && !a.rewSheet.open && !a.at(pageSettings) && !a.at(pageTasks) && !a.at(pageHome) && - !a.copy.on && !a.menu.open && !a.comp.open && !a.pick.open && !a.roster.open && - !a.asking() && !a.roomOpen() + return a.input.empty() && a.state != stateWorking && a.showing() == nil && + !a.rew.on && !a.rewSheet.open && !a.copy.on && !a.menu.open && !a.comp.open && + !a.pick.open && !a.roster.open && !a.asking() && !a.roomOpen() +} + +// ── THE CONVERSATION'S CLOCK ──────────────────────────────────────────────── +// +// A conversation's row is on a clock rather than on events, because what it +// waits for is an absence: nothing pressed and nothing landing for +// [chatHintIdle]. ONE TIMER IS PENDING AT A TIME, AND IT KEEPS ITSELF GOING. +// A key does not arm a clock of its own — a thousand keystrokes would be a +// thousand sleeping goroutines — it stamps [noticeBoard.touched], and the one +// clock, when it lands, measures from the stamp and goes back to sleep for +// what is left ([app.noticeIdleBeat]). It is started once, when the surface +// comes up (app.go's [app.Init]), and every beat arms the next: over a place, +// with hints off, or with nothing to say it simply sleeps the minute again. +// That is one goroutine parked a minute at a time, which is what makes a +// conversation opened from home and then simply read find its tip a minute +// later, without any road into a conversation having to remember to wind it. + +// hintTickMsg is the conversation's clock landing, carrying the arming it +// belongs to. +type hintTickMsg struct{ gen int } + +// hintTick schedules the conversation's clock. +func hintTick(gen int, after time.Duration) tea.Cmd { + return surfaceTick(after, func(time.Time) tea.Msg { return hintTickMsg{gen: gen} }) +} + +// noticeTouched is the person doing something in front of the surface — a key +// pressed anywhere, a turn ending. The tip stands down and the idle clock +// starts again from now. +func (a *app) noticeTouched() { + b := &a.notices + if b.seen == nil { + *b = bareNoticeBoard() + } + b.touched = a.now() + if b.due { + b.due = false + a.touch() + } +} + +// noticeArmIdle starts the conversation's clock, once: a second call while a +// beat is pending answers nil. +func (a *app) noticeArmIdle() tea.Cmd { + b := &a.notices + if b.seen == nil { + *b = bareNoticeBoard() + } + if b.idleArmed { + return nil + } + if b.touched.IsZero() { + b.touched = a.now() + } + b.idleArmed = true + b.idleGen++ + return hintTick(b.idleGen, chatHintIdle) +} + +// noticeIdleBeat is the clock landing, and every beat arms the next. A beat +// from an older arming is dropped. One that finds a place in front, or hints +// off, sleeps the minute again; one that finds the person active goes back to +// sleep for what is left of the minute; one that finds them quiet shows the +// row's tip — and, on every beat after that, moves the row on ([hintEvery]), +// until a key or a turn stamps the board again. +func (a *app) noticeIdleBeat(gen int) tea.Cmd { + b := &a.notices + if gen != b.idleGen { + return nil + } + if !b.enabled || a.showing() != nil { + return hintTick(gen, chatHintIdle) + } + if since := a.now().Sub(b.touched); since < chatHintIdle { + return hintTick(gen, chatHintIdle-since) + } + if b.due { + a.noticeRotate(slotHint) + } else { + // THE TIP STANDING BECOMES VISIBLE NOW, so now is its showing. + b.due = true + b.hidden[slotHint] = false + if id := b.current[slotHint]; id != "" && b.count(id, a.noticeLimit(id)) { + b.save() + } + a.touch() + } + if b.current[slotHint] == "" { + return hintTick(gen, chatHintIdle) + } + return hintTick(gen, hintEvery) } // lastAnswerRunes is how long the newest finished answer is — the fact the diff --git a/internal/tui3/notice_ledger.go b/internal/tui3/notice_ledger.go index c090d70ac8..8f1617a8d5 100644 --- a/internal/tui3/notice_ledger.go +++ b/internal/tui3/notice_ledger.go @@ -61,7 +61,8 @@ type noticeLedger struct { // noticeMark is the ledger's word on one notice. type noticeMark struct { - // Shown counts the SESSIONS the notice was shown in, not the frames. + // Shown counts the SHOWINGS — turns of a row's rotation, on either box — + // and never the frames. Until 2026-09-22 a conversation counted sessions. Shown int `json:"shown,omitempty"` // Retired is when it was retired, RFC 3339, or "" while it is still live. Retired string `json:"retired,omitempty"` diff --git a/internal/tui3/notice_test.go b/internal/tui3/notice_test.go index 9fd356de5a..8cef9d9362 100644 --- a/internal/tui3/notice_test.go +++ b/internal/tui3/notice_test.go @@ -133,10 +133,14 @@ func TestAHintAgesOutAcrossOrdinaryLaunches(t *testing.T) { // The task tip is the one a first exchange arms highest (notice.go's // table); `/ shows every command` stood here until both feet said it. const hint = "task-in-chat" + // A SHOWING IS A VISIBLE ONE: the row draws after a quiet minute, so each + // launch is a turn ending and then a minute of nothing (chattip_test.go + // proves the clock; here it is turned by hand). launch := func() *app { a := noticeApp(t, "") a.turn = 1 a.noticeEvent(eventTurnEnded) + quietMinute(a) return a } for session := 1; session <= noticeShownDefault; session++ { @@ -160,6 +164,17 @@ func TestAHintAgesOutAcrossOrdinaryLaunches(t *testing.T) { // assertions above read as sentences. const hintSlotForTest = slotHint +// quietMinute turns the conversation's clock by hand until the row's tip is +// due (notice.go's [app.noticeIdleBeat]). +func quietMinute(a *app) { + now := time.Date(2026, 9, 22, 12, 0, 0, 0, time.UTC) + a.clock = func() time.Time { return now } + a.noticeTouched() + a.noticeArmIdle() + now = now.Add(chatHintIdle) + a.noticeIdleBeat(a.notices.idleGen) +} + // AND THE NEWS CHANNEL HAS AN OLDER BUILD TO COMPARE AGAINST. It opens on the // second build a profile meets, which on an ordinary launch it never did: the // first build was never written down, so every launch was a first launch and a @@ -261,86 +276,98 @@ func TestTheNoticeLedgerWriteLeavesNoPartialFile(t *testing.T) { func freshBoard() noticeBoard { return newNoticeBoard("", "", true) } -// ONE PER SLOT, AND THE HIGHER PRIORITY WINS. Two armed notices for one slot -// yield one id, and it is the more urgent of the two. -func TestOnePerSlotAndTheHigherPriorityWins(t *testing.T) { +// A ROTATION, NOT A RANKING: the first eligible tip stands, an event without +// an advance keeps it, an advance moves to the next eligible in the table's +// order, and the ring comes round. +func TestASlotRotatesThroughTheEligibleTipsInTableOrder(t *testing.T) { b := freshBoard() cands := []noticeCandidate{ - {id: "low", priority: 10, armed: true}, - {id: "high", priority: 90, armed: true}, - {id: "highest-but-idle", priority: 100, armed: false}, - } - if got := b.pick(slotHint, cands, 1); got != "high" { - t.Fatalf("the slot picked %q, want high", got) + {id: "first", armed: true}, + {id: "idle", armed: false}, + {id: "second", armed: true}, + {id: "third", armed: true}, + } + if got := b.pick(slotHint, cands); got != "first" { + t.Fatalf("the slot picked %q, want the first eligible", got) + } + b.take(slotHint, "first", 6, true) + if got := b.pick(slotHint, cands); got != "first" { + t.Fatalf("an event without an advance moved the slot to %q", got) + } + for _, want := range []string{"second", "third", "first"} { + b.advance[slotHint] = true + got := b.pick(slotHint, cands) + if got != want { + t.Fatalf("the ring went to %q, want %q", got, want) + } + b.take(slotHint, got, 6, true) } - b.take(slotHint, "high", 3, 1) - // The one standing keeps standing against an equal, so the slot does not - // flicker between two hints of the same weight. - cands = append(cands, noticeCandidate{id: "equal", priority: 90, armed: true}) - if got := b.pick(slotHint, cands, 5); got != "high" { - t.Fatalf("an equal took the slot from the one standing: %q", got) + // The note slot rotates on the same terms; with one candidate it is that one. + if got := b.pick(slotNote, []noticeCandidate{{id: "news", armed: true}}); got != "news" { + t.Fatalf("the note slot said %q", got) } } -// THE QUIET GAP. A different hint may not take the slot until [noticeGap] -// turns have passed since it last changed hands — but the slot's first -// occupant waits on nothing, and a slot going empty never waits. -func TestTheHintSlotChangesHandsSlowly(t *testing.T) { +// A TIP THAT HAS JUST BECOME TRUE JUMPS THE RING, once, whether or not the slot +// was asked to move — and then takes its turn like every other row. +func TestAFreshTipJumpsTheRing(t *testing.T) { b := freshBoard() - first := []noticeCandidate{{id: "first", priority: 10, armed: true}} - if got := b.pick(slotHint, first, 1); got != "first" { - t.Fatalf("the first hint of the session waited: %q", got) - } - b.take(slotHint, "first", 3, 1) - - both := append(first, noticeCandidate{id: "second", priority: 50, armed: true}) - if got := b.pick(slotHint, both, 1+noticeGap-1); got != "first" { - t.Fatalf("the slot changed hands inside the gap: %q", got) - } - if got := b.pick(slotHint, both, 1+noticeGap); got != "second" { - t.Fatalf("the slot did not change hands after the gap: %q", got) - } - - // Inside the gap, a standing hint that stopped being armed stands down at - // once and the slot goes quiet rather than jumping to the next one. - b = freshBoard() - b.take(slotHint, "first", 3, 1) - gone := []noticeCandidate{{id: "first", priority: 10, armed: false}, {id: "second", priority: 50, armed: true}} - if got := b.pick(slotHint, gone, 1); got != "" { - t.Fatalf("a disarmed hint was replaced inside the gap: %q", got) - } - - // The note slot has no gap: news is said when it is due. - b = freshBoard() - b.take(slotHint, "first", 3, 1) - if got := b.pick(slotNote, []noticeCandidate{{id: "news", priority: 1, armed: true}}, 1); got != "news" { - t.Fatalf("the note slot waited on the hint slot's gap: %q", got) + cands := []noticeCandidate{ + {id: "compact", armed: false}, + {id: "first", armed: true}, + {id: "second", armed: true}, + } + if got := b.pick(slotHint, cands); got != "first" { + t.Fatalf("the slot picked %q", got) + } + b.take(slotHint, "first", 6, true) + cands[0] = noticeCandidate{id: "compact", armed: true, fresh: true} + if got := b.pick(slotHint, cands); got != "compact" { + t.Fatalf("a fresh tip did not jump the ring: %q", got) + } + b.take(slotHint, "compact", 6, true) + // No longer fresh: an event keeps it, and an advance walks on from it. + cands[0].fresh = false + if got := b.pick(slotHint, cands); got != "compact" { + t.Fatalf("a tip that had jumped was moved by an event: %q", got) + } + b.advance[slotHint] = true + if got := b.pick(slotHint, cands); got != "first" { + t.Fatalf("the ring did not walk on from the fresh tip: %q", got) + } + // A tip disarming stands down at once, for the next eligible. + b.take(slotHint, "first", 6, true) + cands[1].armed = false + if got := b.pick(slotHint, cands); got != "second" { + t.Fatalf("a disarmed tip did not yield: %q", got) } } -// A notice is counted once per session however many events re-decide the slot, -// and its last allowed showing retires it for the sessions after while leaving -// it up for this one. -func TestAShowingIsCountedOncePerSessionAndTheLastOneRetires(t *testing.T) { +// Every change of hands is a showing, a slot re-decided to the same tip is not, +// and the last allowed showing retires the notice while leaving it up. +func TestEveryChangeOfHandsIsAShowingAndTheLastOneRetires(t *testing.T) { b := freshBoard() - b.take(slotHint, "tip", 2, 1) - b.take(slotHint, "", 2, 2) - b.take(slotHint, "tip", 2, 3) + b.take(slotHint, "tip", 3, true) + b.take(slotHint, "tip", 3, true) if got := b.ledger.shown("tip"); got != 1 { - t.Fatalf("one session counted %d showings", got) + t.Fatalf("one standing counted %d showings", got) + } + b.take(slotHint, "", 3, true) + b.take(slotHint, "tip", 3, true) + if got := b.ledger.shown("tip"); got != 2 { + t.Fatalf("a tip coming back counted %d showings, want 2", got) } if b.retired("tip") { - t.Fatal("a first showing retired the notice") + t.Fatal("a second showing retired the notice") } - - // The next session: the second showing is the last allowed. + // The next surface over the same ledger: the third showing is the last. next := newNoticeBoard("", "", true) next.ledger = b.ledger - next.take(slotHint, "tip", 2, 1) + next.take(slotHome, "tip", 3, true) if !next.retired("tip") { t.Fatal("the last allowed showing did not retire the notice") } - if next.current[slotHint] != "tip" { + if next.current[slotHome] != "tip" { t.Fatal("the last allowed showing was not shown") } } @@ -349,13 +376,13 @@ func TestAShowingIsCountedOncePerSessionAndTheLastOneRetires(t *testing.T) { // arming rule is still true. func TestARetiredNoticeNeverReturnsThisSession(t *testing.T) { b := freshBoard() - cands := []noticeCandidate{{id: "tip", priority: 10, armed: true}} - b.take(slotHint, "tip", 3, 1) + cands := []noticeCandidate{{id: "tip", armed: true}} + b.take(slotHint, "tip", 3, true) b.retire("tip") if b.current[slotHint] != "" { t.Fatal("retiring did not clear the slot") } - if got := b.pick(slotHint, cands, 9); got != "" { + if got := b.pick(slotHint, cands); got != "" { t.Fatalf("a retired notice came back: %q", got) } } @@ -408,26 +435,36 @@ func TestAHintArmsDrawsLowestRetiresAndStaysRetired(t *testing.T) { if got := a.notices.current[slotHint]; got != "task-page-after-first-task" { t.Fatalf("a task starting armed %q", got) } - if got := a.footHint(a.width); got != taskPageTip { - t.Fatalf("the hint slot reads %q, want the tip", got) + // THE ROW WAITS FOR A QUIET MINUTE (notice.go's THE CONVERSATION'S CLOCK); + // the clock itself is proved in chattip_test.go, and here the minute is + // taken as passed. + if got := a.noticeHint(); got != "" { + t.Fatalf("the tip drew before the person had been quiet: %q", got) + } + a.notices.due = true + if got := a.noticeHint(); got != taskPageTip { + t.Fatalf("the tip row reads %q, want the tip", got) + } + if got := a.footHint(a.width); strings.Contains(got, taskPageTip) { + t.Fatalf("the keys row still carries the tip: %q", got) } if !strings.Contains(plain(frame(a)), taskPageTip) { t.Fatalf("the tip is not on the frame:\n%s", plain(frame(a))) } - // LOWEST RUNG. A running turn's own key outranks it, and so does a box with - // words in it. + // OVER NOTHING THAT IS HAPPENING. A running turn outranks it, and so does a + // box with words in it. a.state = stateWorking - if got := a.footHint(a.width); got != "ctrl+c interrupt" { - t.Fatalf("a tip outranked a running turn's key: %q", got) + if got := a.noticeHint(); got != "" { + t.Fatalf("a tip drew over a running turn: %q", got) } a.state = stateIdle a.input.setText("half a sentence") - if got := a.footHint(a.width); strings.Contains(got, taskPageTip) { + if got := a.noticeHint(); got != "" { t.Fatalf("a tip drew over a box with words in it: %q", got) } a.input.reset() - if got := a.footHint(a.width); got != taskPageTip { + if got := a.noticeHint(); got != taskPageTip { t.Fatalf("the tip did not come back over an empty box: %q", got) } @@ -462,6 +499,7 @@ func TestAHintArmsDrawsLowestRetiresAndStaysRetired(t *testing.T) { if got := again.notices.current[slotHint]; got == "task-page-after-first-task" { t.Fatal("a retired hint came back after a restart") } + again.notices.due = true if strings.Contains(plain(frame(again)), taskPageTip) { t.Fatal("the tip is drawn after a restart") } @@ -541,12 +579,6 @@ func TestEveryRetireEventIsProvedByItsGesture(t *testing.T) { eventSearchOpened: func(t *testing.T, a *app) { a.slash("/search") }, eventSubharnessOpened: func(t *testing.T, a *app) { a.slash("/subharness") }, eventConnectOpened: func(t *testing.T, a *app) { a.slash("/connect") }, - eventMediaAsked: func(t *testing.T, a *app) { - a.state = stateWorking - drive(t, a, streamEventMsg{gen: a.gen, ev: session.Event{ - Kind: session.EventToolBegin, CallID: "g1", Tool: "generate_image", Hint: "generate_image", - }}) - }, } for _, name := range noticeEvents { if name == eventBoot { @@ -595,20 +627,20 @@ func TestTheCompactHintFollowsTheContextReading(t *testing.T) { t.Fatalf("at 60%% the slot holds %q", got) } agent.weight = 100 - a.turn += noticeGap a.settle() if got := a.notices.current[slotHint]; got == "compact-at-half" { t.Fatal("the compact hint stayed up after the reading fell") } } -// The Display tab's "hints" row silences the slot, and the change lands at the -// next turn end, the way the mouse row's does. +// The Workspace tab's "disable hints" row silences the slot, and the change +// lands at the next turn end, the way the mouse row's does. func TestTheHintsRowSilencesTheSlot(t *testing.T) { a, dir := sheetApp(t) startTask(t, a) - if got := a.footHint(a.width); got != taskPageTip { - t.Fatalf("the hint slot reads %q before the toggle", got) + a.notices.due = true + if got := a.noticeHint(); got != taskPageTip { + t.Fatalf("the tip row reads %q before the toggle", got) } a.openSettings() @@ -630,7 +662,7 @@ func TestTheHintsRowSilencesTheSlot(t *testing.T) { if a.notices.enabled { t.Fatal("the turn end did not re-read the row") } - if got := a.footHint(a.width); got == taskPageTip { + if got := a.noticeHint(); got == taskPageTip { t.Fatal("a silenced slot still draws the tip") } // The next surface over this profile is quiet from the start. @@ -648,7 +680,7 @@ func TestTheHintsRowSilencesTheSlot(t *testing.T) { func TestNewsIsSaidOnceAfterABuildChange(t *testing.T) { saved := notices notices = append([]notice{{ - id: "test-news", slot: slotNote, priority: 1, news: true, maxShown: 1, + id: "test-news", slot: slotNote, news: true, maxShown: 1, armed: func(*app) bool { return true }, text: "new · the test channel is open", }}, saved...) diff --git a/internal/tui3/pages.go b/internal/tui3/pages.go index 6ce0c79a13..a8fbb720e4 100644 --- a/internal/tui3/pages.go +++ b/internal/tui3/pages.go @@ -1407,12 +1407,12 @@ func placeFrameWithBar(a *app, width, height int, // the command, then what it does. // // IT IS RIGHT-ALIGNED, led by a bulb and closed by a cross (hometip.go's - // [app.homeTipLine]), and the cross's columns are recorded as the line is + // [app.tipLine]), and the cross's columns are recorded as the line is // laid out, published below the clamp with the rule's own row. tipTop := -1 a.tipCloseSpan = hudSpan{} if tip := a.noticeHomeHint(); hasBox && tip != "" { - if line, span := a.homeTipLine(tip, width, pal); line != "" { + if line, span := a.tipLine(tip, width, pal); line != "" { a.tipCloseSpan, tipTop = span, len(lines) add(line, nil) } else { diff --git a/internal/tui3/place_search.go b/internal/tui3/place_search.go index 590c3e9f25..5875156b77 100644 --- a/internal/tui3/place_search.go +++ b/internal/tui3/place_search.go @@ -113,10 +113,11 @@ func (a *app) rebuildSearch() { p.reading = next.unfolding(p.unfolded) // AND WHETHER THERE IS AN INDEX AT ALL IS A FACT ABOUT THE SURFACE, not // about the words: it is read here, where the reading is made, so the page - // can tell "nothing was said" from "nothing looked" ([searchNoIndexWord]). - // The hosted case is answered further up by [placeSearch.remote], which says - // WHOSE index is missing and is the better sentence where it applies. - p.reading.noIndex = a.searchStore == nil && !a.hosted() + // can say what it matched by — what was said, or only what the + // conversations are called ([searchByNameWord]). The hosted case is + // answered further up by [placeSearch.remote], which says WHOSE index is + // missing and is the better sentence where it applies. + p.reading.byName = a.searchStore == nil p.cursor = a.nearestSearchStop(p.cursor) } @@ -172,10 +173,15 @@ func (a *app) searchTick(msg searchTickMsg) tea.Cmd { return nil } if a.searchStore == nil { - // A CAPABILITY THAT CANNOT WORK IS ABSENT, NOT BROKEN. With no index - // behind it the place keeps saying what it is for rather than drawing an - // empty result list under somebody's words. - a.search.waiting = false + // WITH NO INDEX BEHIND IT, THE NAMES ARE SEARCHED. Memory off means no + // store and so no record of what was said (cmd/codeaf's v3Memory), and + // until 2026-09-22 this place answered that by refusing to search at + // all — while home's box, one `esc` away, found the same conversations + // by name. So the world this place already holds is read instead, the + // way home reads it: a conversation matches by what it is called and + // what project it is in ([searchByName]), and the page says that is + // what it matched by. The answer needs no round trip, so it lands now. + a.searchDone(searchDoneMsg{ask: a.search.ask, hits: searchByName(a.search.ask.query, a.search.world)}) return nil } return searchCmd(a.searchStore, a.search.ask) diff --git a/internal/tui3/projectseam.go b/internal/tui3/projectseam.go index 7cc48139fd..a45baa5175 100644 --- a/internal/tui3/projectseam.go +++ b/internal/tui3/projectseam.go @@ -1,11 +1,25 @@ package tui3 -import "github.com/charmbracelet/x/ansi" +import ( + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" +) -// seamProjectRight adds the project at the right edge after any telemetry. -// The controls and telemetry keep their space; paths truncate at the right, -// and a field without room for its root and ellipsis disappears altogether. -// The span covers the displayed path alone, relative to the right label. +// seamProjectWord is the conversation's project as every row that names it +// spells it: the workspace under `~`, with the machine in front over a +// connection. One function, because the seam at phone width and the keys +// row everywhere else must agree on the word. +func (a *app) seamProjectWord() string { + return a.hostedPath(a.placeWord(tildePath(a.workspace, a.tilde))) +} + +// seamProjectRight adds the project at the right edge after any telemetry — +// AT THE PHONE TIER ONLY, since 2026-09-22, where the seam is the keys row; +// everywhere else the project is on the keys row under the box (footswap.go's +// [app.hintRow]). The controls and telemetry keep their space; paths truncate +// at the right, and a field without room for its root and ellipsis +// disappears altogether. The span covers the displayed path alone, relative +// to the right label. func seamProjectRight(left, right, path string, width int) (string, hudSpan) { if path == "" { return right, hudSpan{} @@ -31,6 +45,34 @@ func (a *app) paintSeamProject(text string, span hudSpan, hovered bool) string { }, hovered) } +// seamProjectPress is a press on the conversation's project, wherever this +// frame drew it — the keys row, or the seam at phone width +// ([app.hintRowKind]) — and it opens the folder chooser, which is what the +// word is a door onto: the same sheet `/folder` opens. +func (a *app) seamProjectPress(x, y int) (tea.Cmd, bool) { + if a.copy.on || a.pick.open || a.roomOpen() || !a.seamProjectSpan.holds(x) { + return nil, false + } + mark, ok := a.chromeAt(y) + if !ok || mark.kind != a.hintRowKind() { + return nil, false + } + return a.openFolderPick(""), true +} + +// tipClosePress is a press on the cross at the end of the conversation's tip +// row (view.go's [chromeTip]): the tip goes away until the row next changes +// hands (notice.go's [app.noticeDismiss]). It reports whether it took the +// press; the rest of that row is blank, and blank is not a gesture. +func (a *app) tipClosePress(x, y int) bool { + mark, ok := a.chromeAt(y) + if !ok || mark.kind != chromeTip || !a.tipCloseSpan.holds(x) { + return false + } + a.noticeDismiss(slotHint) + return true +} + // seamModelPaint keeps the current model bold and bright even while underlined. func seamModelPaint(pal palette, text string, hovered bool) string { text = pal.seamModel(text) diff --git a/internal/tui3/projectseam_test.go b/internal/tui3/projectseam_test.go index e6e24880f7..2e0f84b07e 100644 --- a/internal/tui3/projectseam_test.go +++ b/internal/tui3/projectseam_test.go @@ -35,25 +35,32 @@ func TestMessageBoxModelAndProjectUnderlineOnHover(t *testing.T) { return frame(a) } draw() - model, project, row := a.seamModelSpan, a.seamProjectSpan, seamRowY(a) - if home { - model, project, row = a.targetModelSpan, a.targetFolderSpan, a.targetRow - } - if !model.pressable() || !project.pressable() { - t.Fatalf("missing spans: model %+v, project %+v", model, project) + // THE MODEL IS ON THE SEAM AND THE PROJECT ON THE KEYS ROW, on both + // boxes (hometip.go, footswap.go's [app.hintRow]). + type door struct { + span hudSpan + row int + painted string } pal := a.pal + var doors []door if home { pal = pal.onPlaces() + doors = []door{ + {a.targetModelSpan, a.targetRow, pal.underline(pal.seamModel("m"))}, + {a.targetFolderSpan, a.footRow, pal.underline(pal.dim("/tmp/hover-project"))}, + } + } else { + doors = []door{ + {a.seamModelSpan, seamRowY(a), pal.underline(pal.seamModel("m"))}, + {a.seamProjectSpan, markedRowY(a, chromeStatus, 0), pal.underline(pal.dim("/tmp/hover-project"))}, + } } - for _, target := range []struct { - span hudSpan - painted string - }{ - {model, pal.underline(pal.seamModel("m"))}, - {project, pal.underline(pal.dim("/tmp/hover-project"))}, - } { - drive(t, a, tea.MouseMotionMsg{X: target.span.from, Y: row}) + for _, target := range doors { + if !target.span.pressable() { + t.Fatalf("missing span: %+v", target.span) + } + drive(t, a, tea.MouseMotionMsg{X: target.span.from, Y: target.row}) if text := draw(); !strings.Contains(text, target.painted) { t.Fatalf("the hovered seam field at %+v did not underline", target.span) } diff --git a/internal/tui3/render.go b/internal/tui3/render.go index 238ce22dd0..a19e2566ff 100644 --- a/internal/tui3/render.go +++ b/internal/tui3/render.go @@ -3316,10 +3316,12 @@ func (a *app) legend(width int) string { ledger, alive = a.seamRungParts(parts, rung.steps) painted, right = a.seamTelemetryLabel(ledger, alive) } - // The project is the final right-hand field, after the numbers. Its - // extra columns never move the ledger's doors relative to that label. + // THE PROJECT IS THE KEYS ROW'S NOW (footswap.go's [app.hintRow]), and + // it stays on the seam only at the phone tier, where the seam IS the + // keys row and the last row is the deck. Its extra columns never move + // the ledger's doors relative to that label. projectSpan := hudSpan{} - if !a.roomOpen() { + if !a.roomOpen() && !telemetry { original := right right, projectSpan = seamProjectRight(left, right, pieces.project, width) if projectSpan.pressable() { @@ -3688,14 +3690,10 @@ func (a *app) footHint(width int) string { if a.chordLost && a.chords.meta == chordMetaWord { return a.chords.chordShortWords() } - // AND UNDER EVERY STATE'S OWN KEYS, THE EARNED HINT (notice.go). It is the - // lowest rung there is — a tip about a gesture the person has not used yet, - // drawn only over an idle box — and it takes the slot from the rest state - // below because that is what the rest state is for: the one line a newcomer - // reads when nothing is happening. - if tip := a.noticeHint(); tip != "" { - return tip - } + // THE EARNED TIP IS NOT ON THIS ROW ANY MORE. Until 2026-09-22 it was the + // rung under the rest state here; it has a row of its own now, over the + // rule, once the person has been quiet for a minute (notice.go's + // [app.noticeHint]), so the keys row is the keys and nothing else. return a.idleHint() } diff --git a/internal/tui3/searchplace.go b/internal/tui3/searchplace.go index 2c572ad763..e4a4f9e77f 100644 --- a/internal/tui3/searchplace.go +++ b/internal/tui3/searchplace.go @@ -9,6 +9,7 @@ package tui3 import ( "fmt" + "path/filepath" "regexp" "sort" "strings" @@ -49,10 +50,12 @@ type searchReading struct { hits []searchHit facets []searchFacet now time.Time - // noIndex says there is no conversation store behind this window at all, so - // that "nobody has said that" and "nothing looked" are two different - // sentences on the page rather than one ([searchNoIndexWord]). - noIndex bool + // byName says there is no conversation store behind this window, so the + // hits are conversations matched by their NAME and project rather than by + // what was said in them ([searchByName]) — and the page says so, because + // "nobody has said that" and "no conversation is called that" are two + // different sentences ([searchByNameWord], [searchNothingNamed]). + byName bool // unfolded is whether every result is drawn rather than the first // [searchShown] and a fold line ([searchReading.unfolding]). unfolded bool @@ -168,21 +171,24 @@ func (r searchReading) paint(width int, pal palette, lit func(line int) bool) [] if room <= 0 { return nil } - if r.noIndex { - // AND A PLACE WITH NO INDEX BEHIND IT SAYS SO. Without this line a machine - // whose store was never wired answered `nothing on this machine says "x"`, - // which is a search that never happened reporting a result — the one - // sentence on this page that could make somebody believe a conversation - // does not exist. - return searchHung(placeTeachProse(searchNoIndexWord, width, pal)) - } if r.query == "" { // NOTHING TYPED IS AN EMPTY PLACE, and it says what arrives here and the // one thing that puts it there — the heading and the whisper every empty - // place draws (placeprose.go's [placeWhisper]). - return placeWhisperLines(pageSearch, width, pal) + // place draws (placeprose.go's [placeWhisper]). AND A PLACE WITH NO + // INDEX BEHIND IT SAYS SO, under the whisper: without that line a + // machine with memory off would answer `nothing on this machine says + // "x"` about words it never indexed — the one sentence on this page that + // could make somebody believe a conversation does not exist. + lines := placeWhisperLines(pageSearch, width, pal) + if r.byName && len(lines) > 0 { + lines = append(lines, placeWhisperLead+pal.dim(fit(searchByNameWord, width-len(placeWhisperLead)))) + } + return lines } if len(r.hits) == 0 { + if r.byName { + return searchHung(placeTeachProse(searchNothingNamed(r.query), width, pal)) + } return searchHung(placeTeachProse(searchNothingSaid(r.query), width, pal)) } var out []string @@ -221,10 +227,50 @@ func searchNothingSaid(query string) string { return fmt.Sprintf("nothing on this machine says %q · try fewer words, or a name", query) } -// searchNoIndexWord is the page over a surface with no conversation store -// wired: A CAPABILITY THAT CANNOT WORK IS ABSENT, NOT BROKEN, and this is the -// sentence that says which of the two silences this one is. -const searchNoIndexWord = "there is no index of this machine's conversations behind this window, so nothing can be searched from here." +// searchByNameWord is the line under the whisper on a surface with no +// conversation store wired — memory off, on this machine. It says what the +// place CAN match here, because a capability that is only half there has to +// say which half (the emptiness law's cousin). +const searchByNameWord = "what was said is not indexed while memory is off · conversations match by their name and project" + +// searchNothingNamed is [searchNothingSaid] said honestly on that surface: +// no conversation is CALLED that, which says nothing about what was said. +func searchNothingNamed(query string) string { + return fmt.Sprintf("no conversation on this machine is named %q · what was said is not indexed while memory is off", query) +} + +// searchByName is the search this place runs with no store behind it: every +// word typed has to appear in the conversation's name, its project's name or +// its folder's name — the same three things home's box matches on — and the +// matches come newest first, as the store's would. A hit carries no quoted +// turn, so its row is the name, the project and the age. +func searchByName(query string, world session.World) []store.ConversationHit { + words := strings.Fields(strings.ToLower(query)) + if len(words) == 0 { + return nil + } + var hits []store.ConversationHit + for _, row := range world.Sessions() { + name := homeName(row) + hay := strings.ToLower(name + " " + row.Project + " " + filepath.Base(strings.TrimSpace(row.ProjectDir))) + all := true + for _, word := range words { + if !strings.Contains(hay, word) { + all = false + break + } + } + if !all { + continue + } + hits = append(hits, store.ConversationHit{MessageHit: store.MessageHit{SessionID: row.ID, Time: row.At}, Title: name}) + } + sort.SliceStable(hits, func(i, j int) bool { return hits[i].Time.After(hits[j].Time) }) + if len(hits) > searchFetch { + hits = hits[:searchFetch] + } + return hits +} func (r searchReading) legend(width int, pal palette) string { parts := make([]string, 0, len(r.facets)) diff --git a/internal/tui3/statusdeck_test.go b/internal/tui3/statusdeck_test.go index d27e50db9b..99f18bef0d 100644 --- a/internal/tui3/statusdeck_test.go +++ b/internal/tui3/statusdeck_test.go @@ -317,7 +317,7 @@ func TestTheWideStatusRowIsByteForByteWhatItIs(t *testing.T) { a.touch() head := "─ deepseek/deepseek-v4-flash " - tail := " $0.31 24k/200k · 12% YOLO idle project: ~/src/codeaf ─" + tail := " $0.31 24k/200k · 12% YOLO idle ─" want := head + strings.Repeat("─", 120-ansi.StringWidth(head)-ansi.StringWidth(tail)) + tail if got := plain(a.legend(120)); got != want { t.Fatalf("the wide seam changed:\n got %q\nwant %q", got, want) diff --git a/internal/tui3/view.go b/internal/tui3/view.go index f09562e02d..08ef81ace1 100644 --- a/internal/tui3/view.go +++ b/internal/tui3/view.go @@ -141,6 +141,11 @@ const ( // is EMPTY apart from the chip, and the chip is right-aligned, so a press on // it is a question about the column as well as the row (jumpchip.go). chromeJump + // chromeTip is the same gap row carrying the tip instead, once the person + // has been quiet for a minute (notice.go). The row is EMPTY apart from the + // tip, right-aligned, and the one thing on it a person can press is the + // cross at its end (projectseam.go's [app.tipClosePress]). + chromeTip // chromeLegend is the rule between the transcript and the box. Its right // end carries the hint slot, and the one thing in that slot a person can // press is the door home (home.go's [app.homeDoorPress]). @@ -676,12 +681,28 @@ func (a *app) chrome(width int) ([]string, []chromeRow, int, int) { jumped := false // addGap spends one row of the ladder, and hands it to the chip if the chip // has not been placed yet. + // AND THE TIP RIDES THE SAME ROW WHEN THE CHIP DOES NOT (notice.go's + // [app.noticeHint]): the chip is a door back to the live edge and outranks + // a sentence; the tip is laid out as home's is (hometip.go's [app.tipLine]), + // and its cross's columns are written here, as the row is laid out. + a.tipCloseSpan = hudSpan{} + tipRow, tipSpan := "", hudSpan{} + if tip := a.noticeHint(); tip != "" { + tipRow, tipSpan = a.tipLine(tip, width, a.pal) + } + tipped := false addGap := func() { if chip != "" && !jumped { jumped = true add(chip, chromeRow{kind: chromeJump}) return } + if tipRow != "" && !tipped { + tipped = true + a.tipCloseSpan = tipSpan + add(tipRow, chromeRow{kind: chromeTip}) + return + } add("", chromeRow{}) } // THE GREETING IS THE HEAD OF THIS BLOCK AND, WHILE IT IS UP, IT IS MOST OF From ced0ee34af53db6cdb0aa652f2fb9f04bc9814fa Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Tue, 22 Sep 2026 10:31:30 -0400 Subject: [PATCH 06/39] chat: /manual puts the question to the model as a turn, ctrl+b freezes home too, the reopen-tab tip names the conversation tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /manual used to print the manual's pages as written, with no model call, and on home that note landed in the conversation behind the screen — typing it looked like nothing happening. It is a turn now: the words go to the model told to answer from the manual tool and to name the page, the transcript keeps what was typed, and on home a conversation opens at the target first. The as-written reading stays at the terminal's `codeaf manual`. ctrl+b on home was the emacs left while the merged tip list promised a freeze there. Home freezes its own rows now the way a room does, the keys row names the reader's keys, a tip never draws over it, and esc gives home back as it was. The ctrl+shift+t tip reads "reopens the last closed conversation tab". Co-Authored-By: Claude Fable 5.1 --- docs/GUIDE.md | 5 +- internal/manual/chat/commands.md | 72 ++++++++------ internal/manual/chat/hints-and-tips.md | 7 +- internal/manual/chat/home.md | 4 +- internal/manual/chat/keys.md | 18 +++- internal/manual/chat_test.go | 9 ++ internal/tui3/app.go | 13 ++- internal/tui3/commands.go | 16 +-- internal/tui3/copymode.go | 77 ++++++++++++++- internal/tui3/helpreach_test.go | 36 ------- internal/tui3/home.go | 14 ++- internal/tui3/homecopy_test.go | 91 +++++++++++++++++ internal/tui3/homeslash.go | 8 +- internal/tui3/input.go | 12 +++ internal/tui3/manualcmd.go | 114 +++++++-------------- internal/tui3/manualcmd_test.go | 132 ++++++++++++++++--------- internal/tui3/notice.go | 4 +- internal/tui3/place_home.go | 12 +++ internal/tui3/render.go | 2 +- 19 files changed, 420 insertions(+), 226 deletions(-) create mode 100644 internal/tui3/homecopy_test.go diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 0401e7b948..2dd64b4b33 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -206,7 +206,7 @@ keeps the last ten cleared drafts; `enter` restores one and `d` lets one go. | `/budget` | `what codeaf may spend · every limit on one tab` | | `/compact` | `summarize the conversation now` | | `/rewind` | `go back to an earlier point · esc esc takes back the last` | -| `/manual` | `codeaf's own manual · every page, one per line` | +| `/manual` | `asks the model what codeaf can do, from its own manual` | | `/help` | `this list` | | `/quit` | `close this conversation` | | `/drafts` | `cleared-but-kept drafts · enter restores one, d lets one go` | @@ -325,7 +325,8 @@ in `config.json`, memory in `graph.db`, and project sessions under `v3/projects/ The Markdown under `internal/manual/chat/` is compiled into codeaf, and the conversation reads it with the `manual` tool to answer questions about its own behaviour. From a terminal `codeaf manual` lists every page and `codeaf manual ""` returns the -sections that answer it; inside the chat it is `/manual`. Build gates require every +sections that answer it; inside the chat `/manual` puts the question to the model with +the manual open, and the answer arrives as a turn. Build gates require every slash command and alias, every tool name, and more than a hundred questions in ordinary language to reach an answering page. diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index a28d465690..a6cae6acca 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -207,9 +207,8 @@ Canonical word, the other words it answers to, its argument form, and what it do | `/files` | — | — | lists what has been made for you; opens, reveals or copies one — over `--host` it opens the browse page for that machine | | `/files` | — | `` | over `--host`, brings that one file back and opens it here | | `/help` | `/?` | — | prints this list | -| `/manual` | — | — | every page of codeaf's own manual, one per line | -| `/manual` | — | `` | prints that page as it is written | -| `/manual` | — | `` | prints the sections that answer it, labelled with page and heading | +| `/manual` | — | — | asks the model what codeaf can do, answered from codeaf's own manual | +| `/manual` | — | `` | puts that question to the model, answered from codeaf's own manual, naming the page | | `/quit` | `/exit`, `/q` | — | leaves | ## /help, /?, /quit, /exit, /q — how do I close just this chat, does closing one conversation quit codeaf @@ -1930,28 +1929,35 @@ list can do it, that ability is simply absent rather than present and failing. A change here lands on the **next** picture, sentence or film — not on the next launch. -## /manual — how do I read the manual, is there a help page, show me the page about X +## /manual — how do I read the manual, is there a help page, show me the page about a command, ask codeaf about itself -`/manual` is codeaf's own manual, printed into the conversation. It is the same writing -the chat reads to answer questions about itself, and it arrives **as it is written** — -nothing is retold, summarized or shortened on the way to you. +`/manual` puts a question about codeaf to the model **with the manual open**. The words +after it go out as a turn of the conversation, told to answer out of codeaf's own manual — +the same pages the chat reads whenever you ask what a key or a command does — and to say +which page the answer came from, so you can go on and read that page yourself. -Three forms, and which one you get is decided by what you type after the word: - -| Typed | What comes back | +| Typed | What happens | |---|---| -| `/manual` | every page, one per line: the name you type to open it, then what that page is about | -| `/manual permissions` | that page, whole, exactly as written | -| `/manual who can see my files` | the sections that answer it, each one labelled with the page and the heading it came from | - -A single word is read as a page **name**. More than one word is read as a **question**, and -the question is answered out of every page at once, so you do not have to know which page -a thing is written on before you can ask about it. The label over each answer — like -`[permissions · What runs without asking]` — is the page you can open next with -`/manual `. - -Nothing here costs anything. The pages are inside codeaf; reading them makes no model -call, so `/manual` spends nothing and works with no key set up and with no connection. +| `/manual` | asks what codeaf can do, and which pages are worth reading first | +| `/manual how do I change the effort level` | puts that question; the answer names the page it came from | + +Your line in the transcript is what you typed — `/manual how do I change the effort level` +— and the answer lands under it the way every answer does. **It is a turn**: it goes to the +model this conversation is on and costs what a turn costs. While an answer is already +coming it steers that turn, exactly as a plain `enter` does. + +**On home it opens a conversation first.** Home is not a conversation, so `/manual` there +is one of the commands that *opens a conversation here first* (see the home page): a +conversation opens at the folder and the model on the rule above the box, home closes, +and the question is sent there. Until 2026-09-22 `/manual` on home printed its answer into +the conversation *behind* home, where nothing could be seen of it — typing it looked like +nothing happening. + +**To read a page as it is written, with no model call**, use the terminal: `codeaf manual` +lists every page and `codeaf manual ` prints one whole (next section). Until +2026-09-22 `/manual` did that in the conversation too — a bare `/manual` listed the pages, +`/manual ` printed one and `/manual ` printed the sections that answered +it, spending nothing — and that reading now lives at the terminal alone. ## codeaf manual — reading the manual from the terminal, without a key and without spending anything @@ -2006,13 +2012,13 @@ The usage one command prints is **read out of the table** `codeaf --help` prints typed out a second time beside the flags, so the two can never disagree about what a command takes or what its codes mean. -## What /manual refuses — a page name that does not exist, and a question with no answer +## What codeaf manual refuses at the terminal — a page name that does not exist, and a question with no answer -A **name** you type is an exact request, so it gets an exact answer or an exact refusal — -never a near miss quietly shown as though you had asked for it. `/manual no-such-page` -says there is no page by that name and prints the list of pages there are, and changes -nothing. From the terminal `codeaf manual no-such-page` does the same and **exits -non-zero**, so a script can tell a missing page from a page it just read. +A **name** you type at the terminal is an exact request, so it gets an exact answer or an +exact refusal — never a near miss quietly shown as though you had asked for it. +`codeaf manual no-such-page` says there is no page by that name, prints the list of pages +there are, changes nothing, and **exits non-zero**, so a script can tell a missing page +from a page it just read. A **question** the manual has nothing on is a different thing, and it is an answer rather than a failure: you are told @@ -2021,11 +2027,13 @@ than a failure: you are told the manual has nothing on that, which usually means codeaf does not do it ``` -followed by the list of pages. From the terminal that exits **0** — the manual saying "no, -codeaf does not do that" is a fact about codeaf, not a broken command. +followed by the list of pages, and the command exits **0** — the manual saying "no, codeaf +does not do that" is a fact about codeaf, not a broken command. -The manual describes **this** conversation surface. It has no pages about anything else, -and it will not answer out of what the model remembers about other programs. +In a conversation `/manual` refuses nothing: the words go to the model, and a question the +manual has no page for is answered by the model saying so. The manual describes **this** +conversation surface. It has no pages about anything else, and the model is told to answer +questions about codeaf out of it rather than out of what it remembers about other programs. ## codeaf --help, and --help on any command — what does this command take, what are its flags, how do I see the usage diff --git a/internal/manual/chat/hints-and-tips.md b/internal/manual/chat/hints-and-tips.md index c43a260a98..969dea58a3 100644 --- a/internal/manual/chat/hints-and-tips.md +++ b/internal/manual/chat/hints-and-tips.md @@ -98,8 +98,8 @@ build if the two disagree), so a tip you saw is on it word for word. - `ctrl+enter sends your message as something to keep true` — retired when a standing order is made or the standing page opened. - `/manual answers any question about codeaf from its own manual` — retired when - `/manual` is typed, bare, with a page or with a question. -- `ctrl+shift+t reopens the tab you just closed` — retired the first time the chord is + `/manual` is typed, bare or with a question. +- `ctrl+shift+t reopens the last closed conversation tab` — retired the first time the chord is pressed, on a terminal that can send it. **Files and context** @@ -148,7 +148,8 @@ build if the two disagree), so a tip you saw is on it word for word. - `/connect links Google, Slack or another model service` — retired when the connect panel is reached for. - `ctrl+b freezes the screen so you can read and copy from it` — after the first exchange. - Retired the first time copy mode opens. + Retired the first time copy mode opens, in a conversation or on home, where the key + freezes home's own screen. Unless a line above says otherwise, a tip is true from the first minute on home and after the first exchange in a conversation. diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index 16361f1baa..9ce603b2de 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -1260,8 +1260,8 @@ one behind your back. This is every fate, in the words the drop-up draws them in | **`opens the page`** | `/settings` `/set` `/config` · `/home` · `/search` · `/spend` · `/standing` · `/memory` `/memories` · `/history` · `/task` (bare) | A place replaces a place, exactly as before. | | **`this list is /resume`** | `/resume` `/sessions` | Says `this list is /resume · enter opens a row` — home *is* that list. | | **`onto home's tray`** | `/attach ` | The file — or picture — rides on home's own tray into the conversation you open next. Home says `attached · notes.md · rides with the next conversation`. A bare `/attach` opens the browser aimed at the next conversation's folder, and a file chosen there lands on the tray. | -| **`opens a conversation here first`** | `/files` · `/crew` (bare) · `/permissions` `/perms` · `/connect` · `/harness` · `/subharness` · `/copy` · `/select` · `/rewind` `/undo` `/back` · `/compact` · `/export` `/save` · `/standing ` · `/task ` | Opens a conversation at the target — the folder and model on the rule above the box — then runs there. Home closes, exactly as `enter` closes it. | -| **`answers here`** | `/help` · `/manual` · `/status` · `/cost` · `/cache` · `/budget` · `/crew ` · `/debug` · `/stop` · `/remember` · `/forget` · a word nobody defined | Answers with a note, and the first line of that note is put on home's own line under the box. `there is no command called /pricing · / lists them` is now something you can read. | +| **`opens a conversation here first`** | `/files` · `/manual` · `/crew` (bare) · `/permissions` `/perms` · `/connect` · `/harness` · `/subharness` · `/copy` · `/select` · `/rewind` `/undo` `/back` · `/compact` · `/export` `/save` · `/standing ` · `/task ` | Opens a conversation at the target — the folder and model on the rule above the box — then runs there. Home closes, exactly as `enter` closes it. `/manual` is on this road since 2026-09-22: it is a question put to the model, so it needs a conversation to be asked in. | +| **`answers here`** | `/help` · `/status` · `/cost` · `/cache` · `/budget` · `/crew ` · `/debug` · `/stop` · `/remember` · `/forget` · a word nobody defined | Answers with a note, and the first line of that note is put on home's own line under the box. `there is no command called /pricing · / lists them` is now something you can read. | | **`runs on the conversation behind home`** | `/land` · `/land ` · `/workspace ` | Acts on the conversation this window is holding behind the screen — not on the one `enter` would open — and its answer is echoed onto home's line. | | **`a fresh conversation behind home`** | `/new` `/clear` `/clean` `/reset` | Replaces the conversation behind the screen and says `started a fresh conversation behind home`. It is not the same act as `enter`, which opens a conversation at the target. | | **`closes the conversation behind home`** | `/quit` `/exit` `/q` | Closes it and says `closed · `. When it was the last conversation this terminal was holding, codeaf leaves. | diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index 71950c302b..ac81287a45 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -585,7 +585,7 @@ key arrives as ordinary `enter` and the message steers instead. | Chord | What it does | |---|---| | `ctrl+o` | Selected landed card: open its output. Selected proposal: open its brief. Inside a task's page: open or fold the long instruction at the top. Otherwise: open or fold the live caption's tool rows; before a live caption exists, open or fold the `N earlier tool calls` fallback. It never opens a `▸ worked` chip — that is `ctrl+e` | -| `ctrl+b` | Enter copy mode — freeze the view so you can read and copy | +| `ctrl+b` | Enter copy mode — freeze the view so you can read and copy. On home it freezes home's own screen the same way | | `ctrl+s` | Hand the pointer to your terminal so you can drag-select. Toggles; any other key takes it back | | `ctrl+,` | Open the settings panel | | `alt+e` | Walk this conversation's thinking rung one step: auto → low → medium → high → xhigh → max, and back to auto. Works with a sentence half typed. On home and every other place it walks the rung of the **next** conversation instead — the effort word after the model’s colon on home’s seam | @@ -2841,13 +2841,27 @@ and `alt+1`…`alt+7` still go everywhere. **A file path is your terminal's click, not codeaf's** — usually **cmd+click** (ctrl+click on Linux). If a plain click on a path does nothing, that is why. -## Copy mode: taking text out of the conversation +## Copy mode: taking text out of the conversation, or off home — ctrl+b, freeze the screen, esc to leave `ctrl+b` freezes the view and hands the keyboard to a reader, so you can pull text out of a surface that runs in the alternate screen where your terminal's own selection is gone. The `/copy` command does the same. Inside a room, `ctrl+b` freezes **the room's rows** rather than the conversation's. +**On home, `ctrl+b` freezes home's own screen** — the list and the cards exactly as they +stand — so a conversation's title or a project's path can be read off and copied with +the same keys. The cursor starts on the row home's cursor was on, `a` takes the whole +item, and `esc` (or `ctrl+b` again) gives home back exactly as it was, box and all. +Until 2026-09-22 `ctrl+b` on home moved the caret one cell left and froze nothing, which +is what a person who read the tip `ctrl+b freezes the screen so you can read and copy +from it` over home's box and pressed it saw: nothing. On the phone-width screen home +keeps its own shape and does not freeze. + +Freezing looks like nothing when nothing is moving — the rows stay where they are on +purpose. What tells you the freeze is on is the keys row, which reads exactly +`v select · a block · y yank · esc`, the highlighted cursor row, and in a conversation +the status word `COPY`. `esc` always leaves. + | Chord | What it does | |---|---| | `up`/`k`, `down`/`j` | Move | diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index 9cf7be3120..d9c41c0d1a 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -542,6 +542,10 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { {"undo what you did to my folder", "choosing-a-folder"}, {"work in that folder directly", "choosing-a-folder"}, {"what does ctrl+b do", "keys"}, + // ctrl+b on home, asked by the person who pressed it there and saw + // nothing, and by the one who wants a title off the list. + {"ctrl+b on home does nothing", "keys"}, + {"can I copy text off the home screen", "keys"}, // The spell-it-out gesture, asked the three ways people meet it: wanting // it, seeing the hint and not knowing what it is, and being unhappy about // what came back. @@ -2620,6 +2624,11 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { {"can I read the manual from the terminal", "commands"}, {"does reading the manual cost anything", "commands"}, {"list every page of the manual", "commands"}, + // /manual is a question put to the model since 2026-09-22, asked by + // somebody who typed it on home and watched a conversation open, and by + // somebody who remembers it printing the page. + {"why did /manual open a conversation", "commands"}, + {"does /manual ask the model or just print the page", "commands"}, // The wave that gave /status a second form. Each of these is asked by // somebody who wants the session's facts for a PROGRAM rather than for // their own eyes — the plain wish, the flag met in the command list, and diff --git a/internal/tui3/app.go b/internal/tui3/app.go index f77e4939ff..22d801dfce 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -7266,13 +7266,12 @@ func (a *app) slash(line string) tea.Cmd { case "manual": a.noticeEvent(eventManualAsked) - // codeaf's own manual, in the conversation, AS WRITTEN (manualcmd.go). - // It is an answer rather than a place for /status' reason — a person who - // asked a question about the product wants it where they can scroll back - // to it — and it is a lookup rather than a turn, so it makes no model - // call and spends nothing. - a.runManualCommand(rest) - return nil + // codeaf's own manual, ASKED OF THE MODEL (manualcmd.go): the words go + // out as a turn of this conversation, told to answer from the manual and + // to name the page. It has been a turn and not a lookup since + // 2026-09-22, so the answer lands where every other answer lands, and + // it spends what a turn spends. + return a.runManualCommand(rest) case "resume": // Two words for one list, the way /settings also answers to /set and diff --git a/internal/tui3/commands.go b/internal/tui3/commands.go index a86ce9680a..0a9ee6c771 100644 --- a/internal/tui3/commands.go +++ b/internal/tui3/commands.go @@ -429,14 +429,14 @@ var commands = []command{ // has just read a list of commands and still does not know what one of them // means is one row away from the page that says. // - // Three rows for one command, /export's reason exactly: the bare form is the - // listing nearly everybody wants and is the only one that can be RUN from - // this list, since [app.runMenu] puts a row that TAKES something into the - // draft instead of running it. The two that take something ride under it - // wearing the "…". - {name: "manual", desc: "codeaf's own manual · every page, one per line"}, - {name: "manual", args: "", desc: "…that page, as it is written"}, - {name: "manual", args: "", desc: "…the sections that answer it, page and heading named"}, + // Two rows for one command, /export's reason exactly: the bare form is the + // only one that can be RUN from this list, since [app.runMenu] puts a row + // that TAKES something into the draft instead of running it. The one that + // takes something rides under it wearing the "…". Both are turns since + // 2026-09-22 — the question goes to the model with the manual open — where + // three rows used to print the pages as written. + {name: "manual", desc: "asks the model what codeaf can do, from its own manual"}, + {name: "manual", args: "", desc: "…puts that question to the model, answered from the manual"}, // AND THE ROW FOR THE DAY SOMETHING GOES WRONG, directly above /help for the // reason /manual sits there: it is the third thing a person reaches for when // they are stuck, after the list of commands and the page that explains one. diff --git a/internal/tui3/copymode.go b/internal/tui3/copymode.go index 876f09ec46..62fdd9effc 100644 --- a/internal/tui3/copymode.go +++ b/internal/tui3/copymode.go @@ -126,6 +126,11 @@ func (a *app) takeMouseBack() bool { return true } +// copyKeysWord is the keys row while the viewport is frozen, under either box: +// the reader's keys are the only keys that work, so they are the only keys the +// row may name. +const copyKeysWord = "v select · a block · y yank · esc" + // copyMode is the frozen viewport's whole state. The zero value is off, except // for mark, which [newApp] sets to -1 — nothing is marked. type copyMode struct { @@ -177,15 +182,83 @@ func (a *app) enterCopy() { a.touch() } +// freezeHome is ctrl+b on home: home's own rows, frozen, the way +// [app.freezeRoom] freezes a room's. +// +// THE TIP THAT NAMES THE KEY DRAWS ON HOME TOO, since the two boxes came to +// share one list of tips (notice.go), and until 2026-09-22 the key on home was +// the emacs `left` — so a person who read `ctrl+b freezes the screen so you +// can read and copy from it` over home's box and pressed it saw nothing +// happen. What there is to copy off home is real: a conversation's title, a +// project's path, a card's sentence, none of which the alt screen lets a +// terminal select. +// +// The snapshot is the body as the last frame drew it — the same call the frame +// makes, painted on the place ladder the frame paints on (home.go's +// [app.homeFrame]) — and the cursor parks on the row home's own cursor was on, +// which is where the person's eye already is. Each row remembers the list line +// it drew, so `a` takes a whole item. The phone tier keeps its own frame and +// does not freeze; there the key does nothing, which is the law about a +// capability that cannot work. +func (a *app) freezeHome() { + if a.copy.on || !a.at(pageHome) || a.home.phone { + return + } + width, room := a.width, a.home.room + if width <= 0 || room <= 0 { + return + } + was := a.pal + a.pal = was.onPlaces() + rows := placeHome{}.body(a, width, room) + a.pal = was + if len(rows) == 0 { + return + } + snapshot := make([]string, 0, len(rows)) + plain := make([]string, 0, len(rows)) + owner := make([]int, 0, len(rows)) + at, parked := len(rows)-1, false + for i, r := range rows { + snapshot = append(snapshot, r.text) + plain = append(plain, ansi.Strip(r.text)) + line := -1 + if mark, ok := r.hit.(homeMark); ok { + line = mark.line + } + owner = append(owner, line) + if !parked && line >= 0 && line == a.home.cursor { + at, parked = i, true + } + } + a.copy = copyMode{on: true, rows: snapshot, text: plain, owner: owner, at: at, top: 0, mark: -1} + a.noticeEvent(eventCopyEntered) + a.touch() +} + +// copyHeight is how many frozen rows the frame shows at once: home's body +// room while home is frozen, the conversation's viewport otherwise. +func (a *app) copyHeight() int { + if a.at(pageHome) { + return max(a.home.room, 1) + } + return a.viewHeight() +} + // exitCopy thaws it and rejoins the live edge, because a reader who has // finished reading wants the conversation back. // // WHICHEVER EDGE WAS FROZEN. A room's rows are what [app.freezeRoom] snapshots, // so thawing back onto the transcript's edge would drop the reader out of the // page they were reading and lose the conversation's scroll on the way (room.go -// carried this as a known seam; the room's own stick is what closes it). +// carried this as a known seam; the room's own stick is what closes it). Home +// has no edge to rejoin: its list is exactly where it was, box and all. func (a *app) exitCopy() { a.copy = copyMode{mark: -1} + if a.at(pageHome) { + a.touch() + return + } if a.room != nil { a.room.stick = true a.roomTouched() @@ -237,7 +310,7 @@ func (a *app) copyKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { func (a *app) copyScroll(delta int) { c := &a.copy c.at = clampInt(c.at+delta, 0, len(c.rows)-1) - height := a.viewHeight() + height := a.copyHeight() if height < 1 { height = 1 } diff --git a/internal/tui3/helpreach_test.go b/internal/tui3/helpreach_test.go index 5b424b6010..ea88a92f75 100644 --- a/internal/tui3/helpreach_test.go +++ b/internal/tui3/helpreach_test.go @@ -19,7 +19,6 @@ import ( tea "charm.land/bubbletea/v2" "github.com/charmbracelet/x/ansi" - "github.com/Agent-Field/codeaf/internal/manual" "github.com/Agent-Field/codeaf/internal/session" "github.com/Agent-Field/codeaf/internal/tui2/tokens" ) @@ -349,41 +348,6 @@ func TestASearchThatFindsNothingSaysWhatToDoAndAMissingIndexSaysSo(t *testing.T) } } -// ── ROW 7: THE MANUAL LISTING ─────────────────────────────────────────────── - -// A LISTING THAT SHOWS A PAGE THAT DOES NOT EXIST is worse than one that shows -// fewer pages. The listing is one line per page — the name, then the title — -// and an ordinary note RE-FLOWS its text, so a long title wrapped and its last -// word landed at the column the page names are in: `/manual` drew a page called -// `later`, and `/manual later` then answered that there is no such page. -func TestTheManualListingNeverInventsAPage(t *testing.T) { - pages := map[string]bool{} - for _, name := range manual.Chat().Pages() { - pages[name] = true - } - for _, width := range []int{60, 80, 120} { - a := newTestApp(&fakeAgent{model: "m"}) - a.width, a.height = width, 40 - a.railAway = true - typeLine(t, a, "/manual") - for _, row := range noticeBlockRows(a, len(a.entries)-1, a.bodyWidth()) { - // The note's own `· ` marker and the indent law's gutter come off - // first: what is left is the row as the listing built it, and its - // first word must be a page. - said := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(row), "·")) - name, _, _ := strings.Cut(said, " ") - if name == "" { - continue - } - if !pages[name] { - t.Fatalf("the listing at %d columns drew a row whose first word is %q, "+ - "which is not a page — /manual %s answers that there is no such page:\n%s", - width, name, name, row) - } - } - } -} - // ── ROW 20: THE ENTRY NOTE ────────────────────────────────────────────────── // A RESUMED CONVERSATION OPENS BY SAYING WHICH CONVERSATION IT IS, on BOTH diff --git a/internal/tui3/home.go b/internal/tui3/home.go index 8c6112416a..9cb9e1373c 100644 --- a/internal/tui3/home.go +++ b/internal/tui3/home.go @@ -2812,8 +2812,12 @@ func (a *app) homeKey(msg tea.KeyPressMsg) tea.Cmd { return nil case "ctrl+b": - h.box.left() - h.build() + // FREEZE HOME (copymode.go's [app.freezeHome]). It was the emacs `left` + // here long after the conversation's box had given the chord up, and + // the tip that names it draws over this box too since the lists merged + // — a key a tip teaches has to do on home what it does everywhere else. + // `←` is untouched. + a.freezeHome() return nil case "ctrl+f": h.box.right() @@ -5203,6 +5207,12 @@ func homeFilesTouched(row session.SessionRow) int { // homeHint names row options before the draft controls while the box is empty. func (a *app) homeHint() string { + // A FROZEN HOME NAMES THE READER'S KEYS AND NOTHING ELSE (copymode.go's + // [app.freezeHome]): they are the only keys that work while it is up, and + // the conversation's foot says the same line for the same reason. + if a.copy.on { + return copyKeysWord + } // AND THE MODEL LIST OVER THE TARGET NAMES ITS OWN THREE KEYS AND NOTHING // ELSE. It has the whole keyboard while it is up (homedraft.go), so the // router's tail would be two keys that do nothing — which is the one state diff --git a/internal/tui3/homecopy_test.go b/internal/tui3/homecopy_test.go new file mode 100644 index 0000000000..98a2a898a7 --- /dev/null +++ b/internal/tui3/homecopy_test.go @@ -0,0 +1,91 @@ +package tui3 + +import ( + "strings" + "testing" +) + +// ── ctrl+b ON HOME ─────────────────────────────────────────────────────────── +// +// The tip `ctrl+b freezes the screen so you can read and copy from it` draws +// over home's box since the two lists merged, and until 2026-09-22 the key on +// home was the emacs `left`: a tip teaching a key that did nothing where it was +// read. Home freezes now the way a room does (copymode.go's [app.freezeHome]), +// and esc gives it back exactly as it was. + +func TestCtrlBFreezesHomeAndEscGivesItBack(t *testing.T) { + a := placeApp(t) + before := placeFrameText(a) + drive(t, a, key("ctrl+b")) + if !a.copy.on { + t.Fatal("ctrl+b on home did not freeze it") + } + if !a.at(pageHome) { + t.Fatal("freezing home left home") + } + if !a.notices.retired("copy-mode") { + t.Fatal("freezing home did not retire the tip that teaches the key") + } + frozen := placeFrameText(a) + if !strings.Contains(frozen, copyKeysWord) { + t.Fatalf("the keys row does not name the reader's keys:\n%s", frozen) + } + if got := a.noticeHomeHint(); got != "" { + t.Fatalf("a tip drew over a frozen home: %q", got) + } + // THE SNAPSHOT IS THE SCREEN. Every row it holds was on the frame the + // moment before the key, and the frame now draws those rows. + if len(a.copy.text) == 0 { + t.Fatal("the snapshot is empty") + } + for _, row := range a.copy.text { + if row = strings.TrimSpace(row); row != "" && !strings.Contains(before, row) { + t.Fatalf("the snapshot holds a row the screen did not: %q", row) + } + } + // THE READER'S KEYS MOVE THE CURSOR, AND NOTHING REACHES THE BOX. + drive(t, a, key("home")) + if a.copy.at != 0 { + t.Fatalf("home did not park the cursor on the first row: %d", a.copy.at) + } + drive(t, a, key("down")) + if want := min(1, len(a.copy.rows)-1); a.copy.at != want { + t.Fatalf("down moved the cursor to %d, want %d", a.copy.at, want) + } + drive(t, a, key("x")) + if !a.home.box.empty() { + t.Fatalf("a letter reached the box through a frozen home: %q", a.home.box.String()) + } + // AND ESC IS THE WAY BACK, onto home, with the box as it was. + drive(t, a, key("esc")) + if a.copy.on { + t.Fatal("esc did not thaw home") + } + if !a.at(pageHome) { + t.Fatal("esc from a frozen home left home") + } + if !a.home.box.empty() { + t.Fatal("thawing home put something in the box") + } + if strings.Contains(placeFrameText(a), copyKeysWord) { + t.Fatal("the keys row still names the reader's keys after esc") + } +} + +// A second press of the chord leaves, as it does in a conversation, and a +// frozen home is not frozen twice. +func TestCtrlBOnAFrozenHomeLeaves(t *testing.T) { + a := placeApp(t) + placeFrameText(a) + drive(t, a, key("ctrl+b")) + if !a.copy.on { + t.Fatal("ctrl+b on home did not freeze it") + } + drive(t, a, key("ctrl+b")) + if a.copy.on { + t.Fatal("a second ctrl+b did not leave copy mode") + } + if !a.at(pageHome) { + t.Fatal("leaving copy mode left home") + } +} diff --git a/internal/tui3/homeslash.go b/internal/tui3/homeslash.go index 54dfbb6721..e3544c0ed8 100644 --- a/internal/tui3/homeslash.go +++ b/internal/tui3/homeslash.go @@ -201,7 +201,11 @@ func homeFate(word, rest string) string { case "land", "workspace": return fateBehind case "files", "permissions", "connect", "harness", "subharness", "autonomy", - "copy", "select", "rewind", "compact", "export", "drafts": + "copy", "select", "rewind", "compact", "export", "drafts", "manual": + // /manual IS HERE SINCE 2026-09-22 and not among the answers: it is a + // turn of a conversation now (manualcmd.go), and a turn needs one. As + // an answer it printed the pages into the conversation BEHIND home, + // where the person who typed it could see nothing happen. return fateNeedsChat case "standing": // Bare it is the standing place; with words it is a card raised in a @@ -240,7 +244,7 @@ func homeFate(word, rest string) string { return fatePlace } return fateAnswers - case "help", "manual", "status", "cost", "budget", "cache", "debug", "update", + case "help", "status", "cost", "budget", "cache", "debug", "update", "stop", "remember", "forget": return fateAnswers } diff --git a/internal/tui3/input.go b/internal/tui3/input.go index abda1dfd68..435fa95b2a 100644 --- a/internal/tui3/input.go +++ b/internal/tui3/input.go @@ -487,6 +487,18 @@ func (a *app) key(msg tea.KeyPressMsg) tea.Cmd { } } + // A FROZEN HOME IS A READER BEFORE IT IS A PLACE (copymode.go's + // [app.freezeHome]). Copy mode's own rung is below this one, under the + // conversation, and a place is modal here — so the frozen rows would never + // be reached, and esc would be home's rather than the reader's. It is read + // on home alone, the one place that can be frozen, and ctrl+c stays the + // door as it does everywhere. + if a.copy.on && a.at(pageHome) { + if cmd, taken := a.copyKey(msg); taken { + return cmd + } + } + if a.pageShowing() && msg.String() != "ctrl+c" { return a.placeKeyPress(msg) } diff --git a/internal/tui3/manualcmd.go b/internal/tui3/manualcmd.go index e854ed492b..032454d907 100644 --- a/internal/tui3/manualcmd.go +++ b/internal/tui3/manualcmd.go @@ -1,91 +1,53 @@ package tui3 -// /manual — WHAT codeaf KNOWS ABOUT ITSELF, READ RATHER THAN RETOLD. +// /manual — A QUESTION ABOUT codeaf, PUT TO THE MODEL WITH THE MANUAL OPEN. // -// The manual (internal/manual's chat pages) had exactly one reader for its whole -// life and it was not the person: the only door onto it was the belt's `manual` -// tool, which is a model call. That means a key, a bill on every lookup, and — -// the part that actually costs something — a PARAPHRASE. What came back was the -// model's retelling of a page, and a retelling is indistinguishable from an -// invention right up until somebody acts on it, which is the exact failure the -// pages were written to prevent. +// The manual (internal/manual's chat pages) is what the model reads to answer +// anything about codeaf itself — the belt's `manual` tool, which the system +// prompt sends every such question to. This command is the person's door onto +// that same reading: the words after `/manual` go to the model as a turn, told +// to answer out of the manual and to say which page the answer came from, and +// the answer lands in the conversation the way every other answer does. // -// So this command hands over the writing itself. It reaches the same corpus the -// tool reaches and prints it AS WRITTEN, with the page and heading over every -// piece, so what is on the screen can be traced back to the page that authorized -// it. It makes no model call and spends nothing: the pages are inside the binary -// and reading them is a lookup, not a turn. +// IT USED TO PRINT THE PAGES AS WRITTEN, with no model call, on the argument +// that a retelling is indistinguishable from an invention until somebody acts +// on it. That door was replaced on 2026-09-22: a person who typed `/manual how +// do I change the effort level` on home saw nothing at all, because the printed +// note landed in the conversation BEHIND home, and what they expected was the +// chosen model's answer in a conversation. The as-written reading lives on +// where it is most wanted — the command line's `codeaf manual` (cmd/codeaf's +// manual.go), for the questions people ask before there is a key to open a +// conversation with. // -// THE COMMAND LINE HAS THE SAME DOOR (cmd/codeaf's manual.go) and it is not a -// duplicate of this one — it is the door for the questions people ask BEFORE -// there is a key to open a conversation with. +// ON HOME THE COMMAND OPENS A CONVERSATION FIRST (homeslash.go's +// [fateNeedsChat]): the folder and the model on the rule above the box, home +// closing behind you, and the question sent there. In a conversation it is a +// turn of that conversation. import ( "strings" - "github.com/Agent-Field/codeaf/internal/manual" + tea "charm.land/bubbletea/v2" ) -// manualChatSections is how many sections a question typed here is answered -// from. It is the number the belt tool hands a model ([manual.DefaultResults]) -// doubled, for the reason the terminal door uses a bigger one: that four is a -// context budget, and a person reading their own manual is not on one. -const manualChatSections = 2 * manual.DefaultResults +// The two sentences the model is handed. The person's own line in the +// transcript is what they typed — `/manual` or `/manual ` — and these +// are the words behind it ([app.submitShown] keeps the two apart). Both name +// the tool so the answer is read out of the pages rather than remembered from +// somewhere else, and both ask for the page, so the person can go on to read it. +const ( + // manualTourAsk is a bare /manual: what codeaf can do, from its own account. + manualTourAsk = "What can codeaf do? Answer from codeaf's own manual — the manual tool — and name the pages worth reading first." + // manualQuestionLead is put in front of a question typed after the word. + manualQuestionLead = "Answer from codeaf's own manual — the manual tool — and say which page it came from: " +) -// runManualCommand is /manual: the pages there are, one page, or the sections -// that answer a question. -// -// ONE WORD IS A NAME AND MORE THAN ONE IS A QUESTION. A name is an exact request -// and gets an exact answer or an exact refusal — never a near miss shown as -// though it had been asked for, which would read as though the page existed — -// and the refusal prints the pages that do exist, because somebody one letter -// away from the name they wanted should not have to guess at it twice. -// THE LISTING IS A BLOCK AND NOT PROSE, and that is the whole of row 7 of the -// polish audit. [manual.Corpus.Listing] builds one line per page — the name a -// person types, then the title the page gives itself — and an ordinary note -// RE-FLOWS its text to the frame ([app.note], render.go's [wrap]). So a title -// longer than the room left after the name column wrapped, and its last word -// landed on the next line flush at the column the page NAMES are in: the first -// list of pages anybody ever sees had a page called `later` on it, and typing -// `/manual later` then answered "there is no manual page named later". A -// listing that shows a page that does not exist is worse than one that shows -// fewer pages. -// -// [app.noteBlock] is the door for exactly this shape — a note whose LINE -// STRUCTURE IS ITS MEANING — and it cuts a line too wide rather than re-flowing -// it, so a long title now ends in an ellipsis on its own row and the name -// column is the only thing at the margin. -func (a *app) runManualCommand(rest string) { +// runManualCommand is /manual: the question, or the tour, sent to the model as +// a turn of this conversation. +func (a *app) runManualCommand(rest string) tea.Cmd { asked := strings.TrimSpace(rest) - switch { - case asked == "": - a.noteBlock(manual.Chat().Listing()) - case strings.ContainsAny(asked, " \t"): - a.runManualQuestion(asked) - default: - text, found := manual.Chat().Page(asked) - if !found { - a.noteBlock("there is no manual page named " + asked + "\n\n" + manual.Chat().Listing()) - return - } - a.note(text) - } -} - -// runManualQuestion answers in the person's own words, out of every page at -// once, so nobody has to know which page a thing is written on before they can -// ask about it. -func (a *app) runManualQuestion(question string) { - sections := manual.Chat().Search(question, manualChatSections) - if len(sections) == 0 { - // NOT A REFUSAL. The manual having nothing on a topic is a fact about - // codeaf worth saying — it usually means the answer is "no, it does not - // do that" — and the pages go under it so the next question is one - // keystroke away rather than a guess. - // AND THE LISTING UNDER IT IS A BLOCK for [app.runManualCommand]'s reason - // exactly: one page per line, cut rather than re-flowed. - a.noteBlock("the manual has nothing on that, which usually means codeaf does not do it\n\n" + manual.Chat().Listing()) - return + if asked == "" { + return a.submitShown(manualTourAsk, "/manual") } - a.note(manual.RenderWhole(sections)) + return a.submitShown(manualQuestionLead+asked, "/manual "+asked) } diff --git a/internal/tui3/manualcmd_test.go b/internal/tui3/manualcmd_test.go index 2ecf231fcb..2adb9d68d7 100644 --- a/internal/tui3/manualcmd_test.go +++ b/internal/tui3/manualcmd_test.go @@ -5,71 +5,105 @@ import ( "testing" "github.com/Agent-Field/codeaf/internal/config" - "github.com/Agent-Field/codeaf/internal/manual" ) -// manualNote runs one /manual form through the dispatch and hands back the note -// it left, the way the loop would. -func manualNote(t *testing.T, a *app, line string) string { +// manualSent runs one /manual form through the dispatch, the way the loop +// would, and hands back what the model was given and what the transcript says +// the person typed — the two halves [app.submitShown] keeps apart. +func manualSent(t *testing.T, a *app, fake *fakeAgent, line string) (sent, shown string) { t.Helper() - if cmd := a.slash(line); cmd != nil { - a.Update(cmd()) + before := len(fake.sent) + typeLine(t, a, line) + if len(fake.sent) != before+1 { + t.Fatalf("%s sent %d messages, want one", line, len(fake.sent)-before) } - return lastNote(t, a) -} - -func TestManualCommandListsThePagesThereAre(t *testing.T) { - a, _ := sheetApp(t) - note := manualNote(t, a, "/manual") - for _, page := range manual.Chat().Pages() { - if !strings.Contains(note, page) { - t.Errorf("the listing does not name the page %q", page) + for i := len(a.entries) - 1; i >= 0; i-- { + if a.entries[i].kind == entryUser { + return fake.sent[before], a.entries[i].text } } + t.Fatalf("%s left no line of the person's in the transcript", line) + return "", "" } -// AS WRITTEN, NOT RETOLD — which is the whole reason this door exists beside the -// model's tool. A page that arrived summarized would be the paraphrase again, -// wearing a slash. -func TestManualCommandShowsAPageAsItIsWritten(t *testing.T) { - a, _ := sheetApp(t) - note := manualNote(t, a, "/manual permissions") - page, found := manual.Chat().Page("permissions") - if !found { - t.Fatal("there is no permissions page to show") - } - if note != page { - t.Errorf("the note is not the page as written (note %d bytes, page %d)", len(note), len(page)) +// A QUESTION IS A TURN, since 2026-09-22: the model is handed the question with +// the manual named as where to answer from, and the transcript keeps what the +// person actually typed. +func TestManualCommandPutsTheQuestionToTheModelWithTheManualOpen(t *testing.T) { + fake := &fakeAgent{model: "m"} + a := newTestApp(fake) + sent, shown := manualSent(t, a, fake, "/manual how do I change the effort level") + if sent != manualQuestionLead+"how do I change the effort level" { + t.Fatalf("the model was handed %q", sent) + } + if !strings.Contains(sent, "manual tool") || !strings.Contains(sent, "which page") { + t.Fatalf("the question does not name the manual and ask for the page: %q", sent) + } + if shown != "/manual how do I change the effort level" { + t.Fatalf("the transcript says %q, not what was typed", shown) + } + if !a.notices.retired("manual-answers") { + t.Fatal("asking did not retire the tip that teaches the command") } } -func TestManualCommandAnswersAQuestionWithLabelledSections(t *testing.T) { - a, _ := sheetApp(t) - note := manualNote(t, a, "/manual who can see my files") - sections := manual.Chat().Search("who can see my files", manualChatSections) - if len(sections) == 0 { - t.Fatal("the question reaches nothing at all") - } - for _, section := range sections { - if !strings.Contains(note, "## "+section.Page+" · "+section.Title) { - t.Errorf("the answer does not say where %s · %s came from", section.Page, section.Title) - } +// A BARE /manual IS THE TOUR — what codeaf can do, from its own account — +// rather than a listing nobody asked the model for. +func TestABareManualAsksTheModelForTheTour(t *testing.T) { + fake := &fakeAgent{model: "m"} + a := newTestApp(fake) + sent, shown := manualSent(t, a, fake, "/manual") + if sent != manualTourAsk { + t.Fatalf("the model was handed %q", sent) + } + if shown != "/manual" { + t.Fatalf("the transcript says %q, not what was typed", shown) + } + if last := a.entries[len(a.entries)-1]; last.kind == entryNote { + t.Fatalf("a bare /manual still prints a note: %q", last.text) } } -// A NAME IS AN EXACT REQUEST. A near miss is refused rather than answered with -// something else, and the refusal leaves the person able to act. -func TestManualCommandRefusesAPageThatDoesNotExist(t *testing.T) { - a, _ := sheetApp(t) - note := manualNote(t, a, "/manual no-such-page") - if !strings.Contains(note, "there is no manual page named no-such-page") { - t.Errorf("the refusal does not name what was asked for: %q", note) - } - for _, page := range manual.Chat().Pages() { - if !strings.Contains(note, page) { - t.Errorf("the refusal does not name the page %q that does exist", page) +// ON HOME THE QUESTION OPENS A CONVERSATION FIRST AND IS ASKED THERE. It used to +// be an answer echoed to home's line, and the answer — the pages, printed — +// went into the conversation behind home, where the person who typed it could +// see nothing happen at all (the owner met it, 2026-09-22). +func TestManualOnHomeOpensAConversationAndAsksThere(t *testing.T) { + lab := newHomeLab(t) + a := lab.door("") + a.key(key("esc")) + if !a.at(pageHome) { + t.Fatal("esc did not open home") + } + if got := homeFate("manual", "how do I change the effort level"); got != fateNeedsChat { + t.Fatalf("the drop-up says /manual %q on home", got) + } + typeLine(t, a, "/manual how do I change the effort level") + if a.at(pageHome) { + t.Fatal("the question did not open a conversation") + } + var fake *fakeAgent + switch agent := a.agent.(type) { + case *switchAgent: + fake = agent.fakeAgent + case *fakeAgent: + fake = agent + default: + t.Fatalf("the conversation that opened runs on a %T", a.agent) + } + if len(fake.sent) == 0 || fake.sent[len(fake.sent)-1] != manualQuestionLead+"how do I change the effort level" { + t.Fatalf("the new conversation was handed %q", fake.sent) + } + said := "" + for i := len(a.entries) - 1; i >= 0; i-- { + if a.entries[i].kind == entryUser { + said = a.entries[i].text + break } } + if said != "/manual how do I change the effort level" { + t.Fatalf("the transcript says %q, not what was typed on home", said) + } } // THE ROW COUNTS THE SEATS THE CODE ACTUALLY SETS. Both /crew rows said "four" diff --git a/internal/tui3/notice.go b/internal/tui3/notice.go index b5a9cc6640..3be9a3dde0 100644 --- a/internal/tui3/notice.go +++ b/internal/tui3/notice.go @@ -378,7 +378,7 @@ var notices = []notice{ { id: "reopen-tab", slot: slotHint, armed: ready, - text: "ctrl+shift+t reopens the tab you just closed", + text: "ctrl+shift+t reopens the last closed conversation tab", retire: eventTabReopened, }, // ── files and context ─────────────────────────────────────────────────── @@ -944,7 +944,7 @@ func (a *app) noticeLine(id string) string { // rest, no list or layer has the keyboard, and no exchange is being read. func (a *app) noticeHomeQuiet() bool { return a.at(pageHome) && a.home.box.empty() && !a.home.cmd.open && !a.home.comp.open && !a.home.searching() && - a.paneExchange() == nil && !a.targetPickShowing() && !a.composer.open && !a.hopShowing() + a.paneExchange() == nil && !a.targetPickShowing() && !a.composer.open && !a.hopShowing() && !a.copy.on } // noticeDismiss is the cross on a tip row: the tip goes away until the row diff --git a/internal/tui3/place_home.go b/internal/tui3/place_home.go index cad9fa707a..062e836c43 100644 --- a/internal/tui3/place_home.go +++ b/internal/tui3/place_home.go @@ -358,6 +358,18 @@ func (placeHome) close(a *app) { a.dropHome() } // body is home's own column, and the pane map beside it: two facts per row, so // the hit is a [homeMark] rather than a line number. func (placeHome) body(a *app, width, room int) []placeRow { + // A FROZEN HOME DRAWS ITS SNAPSHOT (copymode.go's [app.freezeHome]): the + // rows as they stood when ctrl+b was pressed, with the reader's cursor on + // them. Nothing under it is rebuilt, and no row answers the pointer — row + // 14 of a snapshot is not row 14 of the list. + if a.copy.on { + frozen, _ := a.copyRows(width, room) + rows := make([]placeRow, 0, len(frozen)) + for _, r := range frozen { + rows = append(rows, placeRow{text: r.text, hit: homeMark{line: -1, pane: -1}}) + } + return rows + } // AT REST THE BODY IS THE GRID (homegrid.go), and its shape is settled // before it is drawn: the column count, the width and the room all decide // which rows exist — a whisper wraps at its column's width — so any of them diff --git a/internal/tui3/render.go b/internal/tui3/render.go index a19e2566ff..d28e9cd5ef 100644 --- a/internal/tui3/render.go +++ b/internal/tui3/render.go @@ -3842,7 +3842,7 @@ func (a *app) hintWord() string { // (subharness.go). return a.subVerbs() case a.copy.on: - return "v select · a block · y yank · esc" + return copyKeysWord case a.rew.on: // The rewind mode prints its own keys in the bar that replaced the draft // box (rewind.go), and a slot repeating them would be the surface saying From 85d452be52ad0a7db1442c98ae5e95f35826fb40 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:27:56 -0400 Subject: [PATCH 07/39] chat: a showing is a tip that stood twenty seconds, the ledger forgives what the old count spent, ctrl+b leaves home, /autonomy takes the copy tip's seat Every visible change of hands counted as a showing, and every road home is one: an afternoon of stepping through home spent all thirty tips in flashes nobody read, and the owner's row went blank. A showing is now a tip that stood twenty seconds on a row somebody could see, counted when it leaves. The ledger carries a rule number, and a ledger from the old rule is read once with the rows that rule spent forgiven, keeping retired the ones a gesture retired. The home freeze from the previous commit goes back out: ctrl+b on home is the caret key again, and the tip that taught the freeze gives its seat to /autonomy. Co-Authored-By: Claude Fable 5.1 --- internal/manual/chat/hints-and-tips.md | 30 +++-- internal/manual/chat/keys.md | 21 ++- internal/manual/chat_test.go | 4 + internal/tui3/app.go | 1 + internal/tui3/copymode.go | 72 +--------- internal/tui3/home.go | 22 ++-- internal/tui3/homecopy_test.go | 91 ------------- internal/tui3/hometip_test.go | 15 ++- internal/tui3/input.go | 12 -- internal/tui3/notice.go | 147 ++++++++++++++++----- internal/tui3/notice_ledger.go | 29 ++++ internal/tui3/notice_test.go | 176 ++++++++++++++++++++----- internal/tui3/place_home.go | 12 -- 13 files changed, 346 insertions(+), 286 deletions(-) delete mode 100644 internal/tui3/homecopy_test.go diff --git a/internal/manual/chat/hints-and-tips.md b/internal/manual/chat/hints-and-tips.md index 969dea58a3..38f5d555aa 100644 --- a/internal/manual/chat/hints-and-tips.md +++ b/internal/manual/chat/hints-and-tips.md @@ -48,17 +48,30 @@ project has run` never comes back; run `/compact` once and the compact tip is re retired from either box is retired from both: opening the model list on home retires `/model lists every model` in every conversation as well. -A tip you never act on is not shown forever either. Every time a row moves on to a tip -counts as one showing — a turn of home's rotation, a quiet minute in a conversation, the -two-minute turn after it — and once a tip has been shown six times it is taken as read and -retires by itself. A tip nobody could see does not count: home's row deciding while you are -in a conversation, or a conversation's before its quiet minute, is not a showing. +A tip you never act on is not shown forever either. **A showing is a tip that stood for +twenty seconds or more on a row you could see** — home's row while home was in front, a +conversation's row after its quiet minute — and once a tip has been shown six times it is +taken as read and retires by itself. Passing through home for a second or two is not a +showing, however many times you do it, and a row deciding while nobody could see it — +home's while you are in a conversation, a conversation's before its quiet minute — is not +one either. Until 2026-09-22 every visible change of hands counted, so an afternoon of +stepping through home could spend the whole table in flashes nobody read; the first +launch of a build with the twenty-second rule gives back, once, every tip that rule +spent, and leaves retired every tip you retired by using it. This is remembered per profile, in a small file called `notices.json` beside `config.json` in your codeaf profile directory. Retiring is permanent: turning hints off and on does not bring a retired tip back. Deleting that file brings every tip back once; nothing else is in it. +## No hints at all any more, nothing on home's row — every tip has been retired + +When neither row says anything and hints are not turned off, every tip in the table has +retired: you have used what each one teaches, or it stood its six showings. That is the +design working, not a fault — the row over the box is for what you have not found yet. +To see the whole set again, delete `notices.json` from your profile directory; the next +launch starts every tip from nothing. + ## The tip on home changed by itself — the order the tips come round in, and the tip that jumps the queue Both rows take turns through the one list, in the order below, round and round: every tip @@ -147,9 +160,10 @@ build if the two disagree), so a tip you saw is on it word for word. or with a name. - `/connect links Google, Slack or another model service` — retired when the connect panel is reached for. -- `ctrl+b freezes the screen so you can read and copy from it` — after the first exchange. - Retired the first time copy mode opens, in a conversation or on home, where the key - freezes home's own screen. +- `/autonomy sets how questions are handled while you are away` — after the first + exchange. Retired when `/autonomy` is typed, bare or with a rule. (It took the seat + `ctrl+b freezes the screen so you can read and copy from it` held for one build on + 2026-09-22, and `ask for a picture, a voiceover, music or a video` before that.) Unless a line above says otherwise, a tip is true from the first minute on home and after the first exchange in a conversation. diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index ac81287a45..10a1ae3f25 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -585,7 +585,7 @@ key arrives as ordinary `enter` and the message steers instead. | Chord | What it does | |---|---| | `ctrl+o` | Selected landed card: open its output. Selected proposal: open its brief. Inside a task's page: open or fold the long instruction at the top. Otherwise: open or fold the live caption's tool rows; before a live caption exists, open or fold the `N earlier tool calls` fallback. It never opens a `▸ worked` chip — that is `ctrl+e` | -| `ctrl+b` | Enter copy mode — freeze the view so you can read and copy. On home it freezes home's own screen the same way | +| `ctrl+b` | Enter copy mode — freeze the view so you can read and copy. Not on home, where it moves the caret | | `ctrl+s` | Hand the pointer to your terminal so you can drag-select. Toggles; any other key takes it back | | `ctrl+,` | Open the settings panel | | `alt+e` | Walk this conversation's thinking rung one step: auto → low → medium → high → xhigh → max, and back to auto. Works with a sentence half typed. On home and every other place it walks the rung of the **next** conversation instead — the effort word after the model’s colon on home’s seam | @@ -2841,26 +2841,23 @@ and `alt+1`…`alt+7` still go everywhere. **A file path is your terminal's click, not codeaf's** — usually **cmd+click** (ctrl+click on Linux). If a plain click on a path does nothing, that is why. -## Copy mode: taking text out of the conversation, or off home — ctrl+b, freeze the screen, esc to leave +## Copy mode: taking text out of the conversation — ctrl+b, freeze the screen, esc to leave, and ctrl+b on home does nothing of the kind `ctrl+b` freezes the view and hands the keyboard to a reader, so you can pull text out of a surface that runs in the alternate screen where your terminal's own selection is gone. The `/copy` command does the same. Inside a room, `ctrl+b` freezes **the room's rows** rather than the conversation's. -**On home, `ctrl+b` freezes home's own screen** — the list and the cards exactly as they -stand — so a conversation's title or a project's path can be read off and copied with -the same keys. The cursor starts on the row home's cursor was on, `a` takes the whole -item, and `esc` (or `ctrl+b` again) gives home back exactly as it was, box and all. -Until 2026-09-22 `ctrl+b` on home moved the caret one cell left and froze nothing, which -is what a person who read the tip `ctrl+b freezes the screen so you can read and copy -from it` over home's box and pressed it saw: nothing. On the phone-width screen home -keeps its own shape and does not freeze. +**On home `ctrl+b` is not copy mode.** It moves the caret in home's box one cell to the +left, as `←` does, and home's rows are never frozen. Nothing can be copied off the home +screen this way: a title or a path on home is on a row `enter` opens, and inside that +conversation the rows can be frozen. (For one build on 2026-09-22 the key froze home's +own screen; it was taken out the same day.) Freezing looks like nothing when nothing is moving — the rows stay where they are on purpose. What tells you the freeze is on is the keys row, which reads exactly -`v select · a block · y yank · esc`, the highlighted cursor row, and in a conversation -the status word `COPY`. `esc` always leaves. +`v select · a block · y yank · esc`, the highlighted cursor row, and the status word +`COPY`. `esc` always leaves. | Chord | What it does | |---|---| diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index d9c41c0d1a..4653ea85f8 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -2384,6 +2384,10 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { {"is a retired tip gone for good", "hints-and-tips"}, {"a tip appeared in my conversation after a while", "hints-and-tips"}, {"why does the hint only show up when I stop typing", "hints-and-tips"}, + // The showing rule, and the day the row went blank: asked by the + // owner, whose afternoon of stepping through home had spent the table. + {"how long does a tip have to be on screen to count", "hints-and-tips"}, + {"no hints at all any more, home's row is blank", "hints-and-tips"}, {"can I search my conversations with memory off", "places"}, {"search says what was said is not indexed", "places"}, diff --git a/internal/tui3/app.go b/internal/tui3/app.go index 22d801dfce..1badf4d08d 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -6856,6 +6856,7 @@ func (a *app) slash(line string) tea.Cmd { return a.runUpdateCommand(rest) case "autonomy": + a.noticeEvent(eventAutonomyAsked) if rest != "" { return a.changeAutonomy(rest) } diff --git a/internal/tui3/copymode.go b/internal/tui3/copymode.go index 62fdd9effc..abceb85b2d 100644 --- a/internal/tui3/copymode.go +++ b/internal/tui3/copymode.go @@ -182,83 +182,15 @@ func (a *app) enterCopy() { a.touch() } -// freezeHome is ctrl+b on home: home's own rows, frozen, the way -// [app.freezeRoom] freezes a room's. -// -// THE TIP THAT NAMES THE KEY DRAWS ON HOME TOO, since the two boxes came to -// share one list of tips (notice.go), and until 2026-09-22 the key on home was -// the emacs `left` — so a person who read `ctrl+b freezes the screen so you -// can read and copy from it` over home's box and pressed it saw nothing -// happen. What there is to copy off home is real: a conversation's title, a -// project's path, a card's sentence, none of which the alt screen lets a -// terminal select. -// -// The snapshot is the body as the last frame drew it — the same call the frame -// makes, painted on the place ladder the frame paints on (home.go's -// [app.homeFrame]) — and the cursor parks on the row home's own cursor was on, -// which is where the person's eye already is. Each row remembers the list line -// it drew, so `a` takes a whole item. The phone tier keeps its own frame and -// does not freeze; there the key does nothing, which is the law about a -// capability that cannot work. -func (a *app) freezeHome() { - if a.copy.on || !a.at(pageHome) || a.home.phone { - return - } - width, room := a.width, a.home.room - if width <= 0 || room <= 0 { - return - } - was := a.pal - a.pal = was.onPlaces() - rows := placeHome{}.body(a, width, room) - a.pal = was - if len(rows) == 0 { - return - } - snapshot := make([]string, 0, len(rows)) - plain := make([]string, 0, len(rows)) - owner := make([]int, 0, len(rows)) - at, parked := len(rows)-1, false - for i, r := range rows { - snapshot = append(snapshot, r.text) - plain = append(plain, ansi.Strip(r.text)) - line := -1 - if mark, ok := r.hit.(homeMark); ok { - line = mark.line - } - owner = append(owner, line) - if !parked && line >= 0 && line == a.home.cursor { - at, parked = i, true - } - } - a.copy = copyMode{on: true, rows: snapshot, text: plain, owner: owner, at: at, top: 0, mark: -1} - a.noticeEvent(eventCopyEntered) - a.touch() -} - -// copyHeight is how many frozen rows the frame shows at once: home's body -// room while home is frozen, the conversation's viewport otherwise. -func (a *app) copyHeight() int { - if a.at(pageHome) { - return max(a.home.room, 1) - } - return a.viewHeight() -} - // exitCopy thaws it and rejoins the live edge, because a reader who has // finished reading wants the conversation back. // // WHICHEVER EDGE WAS FROZEN. A room's rows are what [app.freezeRoom] snapshots, // so thawing back onto the transcript's edge would drop the reader out of the // page they were reading and lose the conversation's scroll on the way (room.go -// carried this as a known seam; the room's own stick is what closes it). Home -// has no edge to rejoin: its list is exactly where it was, box and all. +// carried this as a known seam; the room's own stick is what closes it). func (a *app) exitCopy() { a.copy = copyMode{mark: -1} - if a.at(pageHome) { - a.touch() - return - } if a.room != nil { a.room.stick = true a.roomTouched() @@ -310,7 +242,7 @@ func (a *app) copyKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { func (a *app) copyScroll(delta int) { c := &a.copy c.at = clampInt(c.at+delta, 0, len(c.rows)-1) - height := a.copyHeight() + height := a.viewHeight() if height < 1 { height = 1 } diff --git a/internal/tui3/home.go b/internal/tui3/home.go index 9cb9e1373c..e34bfee462 100644 --- a/internal/tui3/home.go +++ b/internal/tui3/home.go @@ -1193,6 +1193,9 @@ func (a *app) closeHome() { // this home from re-arming itself into the next one ([homeTickMsg]). func (a *app) dropHome() { a.homeGen++ + // THE TIP ON HOME'S ROW GOES OUT OF SIGHT HERE, so here is where its + // standing is measured (notice.go's [app.noticeSettle]). + a.noticeSettle(slotHome) // CLOSING IS THE LOOK. The stamp the next open measures news against is // written here and only here — see [homeView.seen] for why not on the way // in, and session's look.go for why a window that dies instead loses @@ -2812,12 +2815,13 @@ func (a *app) homeKey(msg tea.KeyPressMsg) tea.Cmd { return nil case "ctrl+b": - // FREEZE HOME (copymode.go's [app.freezeHome]). It was the emacs `left` - // here long after the conversation's box had given the chord up, and - // the tip that names it draws over this box too since the lists merged - // — a key a tip teaches has to do on home what it does everywhere else. - // `←` is untouched. - a.freezeHome() + // THE EMACS LEFT, and not copy mode: for one build on 2026-09-22 the + // chord froze home's own rows the way it freezes a conversation's, and + // the owner found nothing worth copying off a screen whose every row + // is a door — so home keeps the caret key its box has always had, and + // the tip that taught the freeze was replaced (notice.go). + h.box.left() + h.build() return nil case "ctrl+f": h.box.right() @@ -5207,12 +5211,6 @@ func homeFilesTouched(row session.SessionRow) int { // homeHint names row options before the draft controls while the box is empty. func (a *app) homeHint() string { - // A FROZEN HOME NAMES THE READER'S KEYS AND NOTHING ELSE (copymode.go's - // [app.freezeHome]): they are the only keys that work while it is up, and - // the conversation's foot says the same line for the same reason. - if a.copy.on { - return copyKeysWord - } // AND THE MODEL LIST OVER THE TARGET NAMES ITS OWN THREE KEYS AND NOTHING // ELSE. It has the whole keyboard while it is up (homedraft.go), so the // router's tail would be two keys that do nothing — which is the one state diff --git a/internal/tui3/homecopy_test.go b/internal/tui3/homecopy_test.go deleted file mode 100644 index 98a2a898a7..0000000000 --- a/internal/tui3/homecopy_test.go +++ /dev/null @@ -1,91 +0,0 @@ -package tui3 - -import ( - "strings" - "testing" -) - -// ── ctrl+b ON HOME ─────────────────────────────────────────────────────────── -// -// The tip `ctrl+b freezes the screen so you can read and copy from it` draws -// over home's box since the two lists merged, and until 2026-09-22 the key on -// home was the emacs `left`: a tip teaching a key that did nothing where it was -// read. Home freezes now the way a room does (copymode.go's [app.freezeHome]), -// and esc gives it back exactly as it was. - -func TestCtrlBFreezesHomeAndEscGivesItBack(t *testing.T) { - a := placeApp(t) - before := placeFrameText(a) - drive(t, a, key("ctrl+b")) - if !a.copy.on { - t.Fatal("ctrl+b on home did not freeze it") - } - if !a.at(pageHome) { - t.Fatal("freezing home left home") - } - if !a.notices.retired("copy-mode") { - t.Fatal("freezing home did not retire the tip that teaches the key") - } - frozen := placeFrameText(a) - if !strings.Contains(frozen, copyKeysWord) { - t.Fatalf("the keys row does not name the reader's keys:\n%s", frozen) - } - if got := a.noticeHomeHint(); got != "" { - t.Fatalf("a tip drew over a frozen home: %q", got) - } - // THE SNAPSHOT IS THE SCREEN. Every row it holds was on the frame the - // moment before the key, and the frame now draws those rows. - if len(a.copy.text) == 0 { - t.Fatal("the snapshot is empty") - } - for _, row := range a.copy.text { - if row = strings.TrimSpace(row); row != "" && !strings.Contains(before, row) { - t.Fatalf("the snapshot holds a row the screen did not: %q", row) - } - } - // THE READER'S KEYS MOVE THE CURSOR, AND NOTHING REACHES THE BOX. - drive(t, a, key("home")) - if a.copy.at != 0 { - t.Fatalf("home did not park the cursor on the first row: %d", a.copy.at) - } - drive(t, a, key("down")) - if want := min(1, len(a.copy.rows)-1); a.copy.at != want { - t.Fatalf("down moved the cursor to %d, want %d", a.copy.at, want) - } - drive(t, a, key("x")) - if !a.home.box.empty() { - t.Fatalf("a letter reached the box through a frozen home: %q", a.home.box.String()) - } - // AND ESC IS THE WAY BACK, onto home, with the box as it was. - drive(t, a, key("esc")) - if a.copy.on { - t.Fatal("esc did not thaw home") - } - if !a.at(pageHome) { - t.Fatal("esc from a frozen home left home") - } - if !a.home.box.empty() { - t.Fatal("thawing home put something in the box") - } - if strings.Contains(placeFrameText(a), copyKeysWord) { - t.Fatal("the keys row still names the reader's keys after esc") - } -} - -// A second press of the chord leaves, as it does in a conversation, and a -// frozen home is not frozen twice. -func TestCtrlBOnAFrozenHomeLeaves(t *testing.T) { - a := placeApp(t) - placeFrameText(a) - drive(t, a, key("ctrl+b")) - if !a.copy.on { - t.Fatal("ctrl+b on home did not freeze it") - } - drive(t, a, key("ctrl+b")) - if a.copy.on { - t.Fatal("a second ctrl+b did not leave copy mode") - } - if !a.at(pageHome) { - t.Fatal("leaving copy mode left home") - } -} diff --git a/internal/tui3/hometip_test.go b/internal/tui3/hometip_test.go index 110f4dca46..0fea9cb8fd 100644 --- a/internal/tui3/hometip_test.go +++ b/internal/tui3/hometip_test.go @@ -170,11 +170,14 @@ func TestATipSpentOnHomeIsSpentEverywhere(t *testing.T) { } } -// Every turn of a row's rotation is a showing, on either box, and a tip that -// has come round [noticeShownDefault] times is taken as read. -func TestEveryTurnOfTheRotationIsAShowing(t *testing.T) { +// Every turn of a row's rotation that stood long enough to be read is a +// showing, on either box, and a tip that has come round [noticeShownDefault] +// times that way is taken as read. +func TestEveryTurnOfTheRotationThatStoodIsAShowing(t *testing.T) { for _, slot := range []noticeSlot{slotHome, slotHint} { b := bareNoticeBoard() + now := time.Date(2026, 9, 22, 9, 0, 0, 0, time.UTC) + limit := func(string) int { return noticeShownDefault } cands := []noticeCandidate{{id: "a", armed: true}, {id: "b", armed: true}} turns := map[string]int{} for i := 0; i < 2*noticeShownDefault; i++ { @@ -183,9 +186,11 @@ func TestEveryTurnOfTheRotationIsAShowing(t *testing.T) { if id == "" { t.Fatalf("turn %d put nothing on the row", i) } - b.take(slot, id, noticeShownDefault, true) + b.take(slot, id, true, now, limit) turns[id]++ + now = now.Add(noticeReadTime) } + b.settle(slot, now, limit) if turns["a"] != noticeShownDefault || turns["b"] != noticeShownDefault { t.Fatalf("the ring did not share the turns evenly: %v", turns) } @@ -209,7 +214,7 @@ func TestARowHoldsBetweenVisitsAndYieldsWhenSpent(t *testing.T) { if got := b.pick(slotHome, cands); got != "a" { t.Fatalf("the ring did not start at the top: %q", got) } - b.take(slotHome, "a", noticeShownDefault, true) + b.take(slotHome, "a", true, time.Now(), func(string) int { return noticeShownDefault }) // An event with nothing advancing keeps the one standing. if got := b.pick(slotHome, cands); got != "a" { t.Fatalf("an event moved the row without a visit, to %q", got) diff --git a/internal/tui3/input.go b/internal/tui3/input.go index 435fa95b2a..abda1dfd68 100644 --- a/internal/tui3/input.go +++ b/internal/tui3/input.go @@ -487,18 +487,6 @@ func (a *app) key(msg tea.KeyPressMsg) tea.Cmd { } } - // A FROZEN HOME IS A READER BEFORE IT IS A PLACE (copymode.go's - // [app.freezeHome]). Copy mode's own rung is below this one, under the - // conversation, and a place is modal here — so the frozen rows would never - // be reached, and esc would be home's rather than the reader's. It is read - // on home alone, the one place that can be frozen, and ctrl+c stays the - // door as it does everywhere. - if a.copy.on && a.at(pageHome) { - if cmd, taken := a.copyKey(msg); taken { - return cmd - } - } - if a.pageShowing() && msg.String() != "ctrl+c" { return a.placeKeyPress(msg) } diff --git a/internal/tui3/notice.go b/internal/tui3/notice.go index 3be9a3dde0..1a7a10ad9a 100644 --- a/internal/tui3/notice.go +++ b/internal/tui3/notice.go @@ -185,6 +185,9 @@ const ( eventSubharnessOpened = "subharness-opened" // eventConnectOpened is the connect panel reached for (connectpanel.go). eventConnectOpened = "connect-opened" + // eventAutonomyAsked is /autonomy reaching its command, bare or with a + // rule (autonomysheet.go). + eventAutonomyAsked = "autonomy-asked" ) // noticeEvents is every event there is, in one list, so the table check can @@ -198,7 +201,7 @@ var noticeEvents = []string{ eventFolderPicked, eventModelListOpened, eventCrewShown, eventBudgetShown, eventSpendOpened, eventSteered, eventQueued, eventChatStarted, eventPlaceJumped, eventRemembered, eventSearchOpened, eventSubharnessOpened, - eventConnectOpened, + eventConnectOpened, eventAutonomyAsked, } // notice is one thing the surface may tell a person, and the whole of the rule @@ -240,6 +243,16 @@ type notice struct { // surface nagging. const noticeShownDefault = 6 +// noticeReadTime is how long a tip has to stand on a row somebody can see +// before that counts as a showing. Until 2026-09-22 every change of hands +// counted, and every road home is a change of hands — so an afternoon of +// stepping through home to check something else spent every tip on the ring +// in one-second flashes nobody read, and the owner's ledger closed the whole +// table before evening. Twenty seconds is longer than a bounce through home +// and shorter than any reading of a line: a tip that stood that long on a +// visible row was on a screen somebody was looking at. +const noticeReadTime = 20 * time.Second + // hintEvery is how long a tip stands on a row before the next one takes it, // while the row is left at rest: home at rest, or a conversation the person // has gone quiet in. Two minutes is long enough to be read and short enough @@ -489,14 +502,17 @@ var notices = []notice{ retire: eventConnectOpened, }, { - // It took the seat `ask for a picture, a voiceover, music or a video` - // held until 2026-09-22 (the owner's call): copy mode is the one door - // on this surface with nothing on screen pointing at it, because the - // alt screen takes the terminal's own selection away (copymode.go). - id: "copy-mode", slot: slotHint, + // The seat `ask for a picture, a voiceover, music or a video` held + // until 2026-09-22, and `ctrl+b freezes the screen so you can read + // and copy from it` for one build the same day, both the owner's + // call: copy mode was judged no use, and the rule for what happens + // to a question while nobody is at the keyboard is the one setting a + // person cannot guess exists until it has already decided something + // for them (autonomysheet.go). + id: "autonomy-rule", slot: slotHint, armed: spoken, - text: "ctrl+b freezes the screen so you can read and copy from it", - retire: eventCopyEntered, + text: "/autonomy sets how questions are handled while you are away", + retire: eventAutonomyAsked, }, } @@ -616,6 +632,11 @@ type noticeBoard struct { // at is when each slot last changed hands, or zero when it never has; // [hintEvery] is measured from it by the beats. at [noticeSlots]time.Time + // since is when the tip standing in each slot became VISIBLE — the row in + // front and the tip on it — or zero while it cannot be seen. A showing is + // counted from it when the tip leaves or the row goes out of sight + // ([noticeBoard.settle]), and only if it stood [noticeReadTime]. + since [noticeSlots]time.Time // hidden is the cross on a row having been pressed: the tip standing is // not drawn until the slot next changes hands, which clears it. It is this // session's and never the ledger's — putting a tip away is not using it. @@ -655,6 +676,15 @@ func newNoticeBoard(path, build string, enabled bool) noticeBoard { seen: map[string]bool{}, done: map[string]bool{}, } + // A LEDGER FROM THE OLD COUNTING RULE IS FORGIVEN ONCE, on the way in + // (notice_ledger.go's [noticeLedgerRule]): the tips it spent on flashes + // come back, the ones a gesture retired stay retired, and the rule is + // written down so this happens exactly once per profile. + if b.ledger.Rule < noticeLedgerRule { + b.ledger.forgive(noticeShownDefault) + b.ledger.Rule = noticeLedgerRule + b.save() + } // A FIRST LAUNCH HAS NO NEWS. Nothing is new to somebody who has never // seen the older build; the channel opens on the second build a profile // meets. The build is written down either way, so the next change counts. @@ -748,35 +778,67 @@ func (b *noticeBoard) pick(slot noticeSlot, cands []noticeCandidate) string { return "" } -// take records that a slot now holds id — counting the showing when the row -// is live, and retiring the notice when this showing was its last allowed. It -// reports whether the slot's occupant changed, and whether the ledger did. +// take records that a slot now holds id. The one standing before is settled +// first — its showing counted if it stood long enough to be read — and the +// new one's standing starts now when the row is live. It reports whether the +// slot's occupant changed, and whether the ledger did. // -// EVERY VISIBLE CHANGE OF HANDS IS A SHOWING, on either box: a tip that has -// come round six times has been read six times, however many launches or -// visits that took ([noticeShownDefault]). A slot re-decided to the same tip -// is not a showing, which is what keeps an hour of events on one tip at one; -// and a slot deciding while its row cannot be seen — home's while a -// conversation is in front, the conversation's before its quiet minute — is -// not one either, because what has not been read has not been shown -// ([app.noticeLive]). -func (b *noticeBoard) take(slot noticeSlot, id string, limit int, live bool) (changed, wrote bool) { +// A SHOWING IS A TIP THAT STOOD [noticeReadTime] ON A ROW SOMEBODY COULD SEE. +// It is counted when the tip LEAVES — the slot changing hands, the row going +// out of sight — rather than when it arrives, because only then is it known +// how long it stood ([noticeBoard.settle]). A slot re-decided to the same tip +// is nothing at all, which is what keeps an hour of events on one tip at one +// showing; and a slot deciding while its row cannot be seen — home's while a +// conversation is in front, the conversation's before its quiet minute — +// starts no standing, because what has not been read has not been shown +// ([app.noticeLive]). Until 2026-09-22 every visible change of hands counted, +// and [noticeReadTime] says what that cost. +func (b *noticeBoard) take(slot noticeSlot, id string, live bool, now time.Time, limitOf func(string) int) (changed, wrote bool) { if b.current[slot] == id { return false, false } + wrote = b.settle(slot, now, limitOf) b.current[slot] = id if id == "" || !live { - return true, false + return true, wrote + } + if slot == slotNote { + // A NEWS LINE IS SAID, NOT STOOD: the transcript has it the moment it + // is decided ([app.noticeShow]), so deciding it is its showing. + return true, b.count(id, limitOf(id)) || wrote + } + b.since[slot] = now + return true, wrote +} + +// visible says the tip standing in a slot can be seen from now on — the row +// came into view with the tip already on it — and starts its standing unless +// one is already running. It is the other half of [noticeBoard.take], for the +// tip that was decided before the row was in front. +func (b *noticeBoard) visible(slot noticeSlot, now time.Time) { + if b.current[slot] != "" && b.since[slot].IsZero() { + b.since[slot] = now + } +} + +// settle ends the standing of the tip in a slot, counting a showing when it +// stood [noticeReadTime] or more, and retiring the notice when that showing +// was its last allowed. It reports whether the ledger changed. A slot with no +// standing running — nothing on it, or a row nobody could see — settles to +// nothing. +func (b *noticeBoard) settle(slot noticeSlot, now time.Time, limitOf func(string) int) bool { + id, since := b.current[slot], b.since[slot] + b.since[slot] = time.Time{} + if id == "" || since.IsZero() || now.Sub(since) < noticeReadTime { + return false } - return true, b.count(id, limit) + return b.count(id, limitOf(id)) } // count records one showing of id, retiring it when that was its last // allowed, and reports that the ledger changed. func (b *noticeBoard) count(id string, limit int) bool { if b.ledger.show(id) >= limit { - // The last allowed showing is still a showing: the line stays up for - // now and the ledger closes the book on it for the next time. b.ledger.retire(id) } return true @@ -831,7 +893,6 @@ func (a *app) noticeFill(slot noticeSlot) bool { b.armed[slot] = make(map[string]bool, len(notices)) } cands := make([]noticeCandidate, 0, len(notices)) - limits := make(map[string]int, len(notices)) for _, n := range notices { if !n.draws(slot) || b.done[n.id] || b.retired(n.id) { continue @@ -842,10 +903,15 @@ func (a *app) noticeFill(slot noticeSlot) bool { armed := n.armed(a) cands = append(cands, noticeCandidate{id: n.id, armed: armed, fresh: armed && !b.armed[slot][n.id]}) b.armed[slot][n.id] = armed - limits[n.id] = n.limit() } id := b.pick(slot, cands) - changed, wrote := b.take(slot, id, limits[id], a.noticeLive(slot)) + live, now := a.noticeLive(slot), a.now() + changed, wrote := b.take(slot, id, live, now, a.noticeLimit) + // A ROW IN FRONT WITH A TIP ON IT IS BEING SHOWN, whether the tip was + // decided just now or before the row came into view. + if live { + b.visible(slot, now) + } if changed { // A new tip is a new thing to read: the clock starts again and a cross // pressed over the old one is spent. @@ -871,6 +937,19 @@ func (a *app) noticeLive(slot noticeSlot) bool { return true } +// noticeSettle ends the standing of a slot's tip because its row is going out +// of sight — home being left, a conversation's row hidden by a key — and +// writes the ledger when that standing was a showing ([noticeBoard.settle]). +func (a *app) noticeSettle(slot noticeSlot) { + b := &a.notices + if b.seen == nil { + return + } + if b.settle(slot, a.now(), a.noticeLimit) { + b.save() + } +} + // noticeLimit is the showing limit of the notice with this id. func (a *app) noticeLimit(id string) int { for _, n := range notices { @@ -944,13 +1023,15 @@ func (a *app) noticeLine(id string) string { // rest, no list or layer has the keyboard, and no exchange is being read. func (a *app) noticeHomeQuiet() bool { return a.at(pageHome) && a.home.box.empty() && !a.home.cmd.open && !a.home.comp.open && !a.home.searching() && - a.paneExchange() == nil && !a.targetPickShowing() && !a.composer.open && !a.hopShowing() && !a.copy.on + a.paneExchange() == nil && !a.targetPickShowing() && !a.composer.open && !a.hopShowing() } // noticeDismiss is the cross on a tip row: the tip goes away until the row // next changes hands — the next visit to home, the next turn of its clock — // and nothing is written down, because a tip put away is not a tip learned. func (a *app) noticeDismiss(slot noticeSlot) { + // A tip put away has been seen, for as long as it stood. + a.noticeSettle(slot) a.notices.hidden[slot] = true a.touch() } @@ -1025,7 +1106,10 @@ func (a *app) noticeTouched() { } b.touched = a.now() if b.due { + // The row goes out of sight with the key, so the tip on it has stood + // for as long as it is going to. b.due = false + a.noticeSettle(slotHint) a.touch() } } @@ -1068,12 +1152,11 @@ func (a *app) noticeIdleBeat(gen int) tea.Cmd { if b.due { a.noticeRotate(slotHint) } else { - // THE TIP STANDING BECOMES VISIBLE NOW, so now is its showing. + // THE TIP STANDING BECOMES VISIBLE NOW, so its standing starts now; + // whether it was a showing is known when it leaves. b.due = true b.hidden[slotHint] = false - if id := b.current[slotHint]; id != "" && b.count(id, a.noticeLimit(id)) { - b.save() - } + b.visible(slotHint, a.now()) a.touch() } if b.current[slotHint] == "" { diff --git a/internal/tui3/notice_ledger.go b/internal/tui3/notice_ledger.go index 8f1617a8d5..7972e4e549 100644 --- a/internal/tui3/notice_ledger.go +++ b/internal/tui3/notice_ledger.go @@ -55,10 +55,39 @@ func noticeLedgerPath(profileDir string) string { type noticeLedger struct { // Build is the build the news channel last ran under. Build string `json:"build,omitempty"` + // Rule is which counting rule the showings were counted under — see + // [noticeLedgerRule]. A ledger with none was written under the first. + Rule int `json:"rule,omitempty"` // Seen is one mark per notice id that has ever been shown or retired. Seen map[string]noticeMark `json:"seen,omitempty"` } +// noticeLedgerRule is the counting rule this build writes showings under. +// +// RULE 1, until 2026-09-22, counted every visible change of hands as a +// showing, and a ledger written under it is full of tips retired by six +// one-second flashes on the way through home. RULE 2 counts a tip only once it +// has stood [noticeReadTime] (notice.go). A ledger from an older rule is read +// once with the rows the old rule spent forgiven — retired with the full count +// of showings and nothing else — so that what the old rule threw away comes +// back exactly once, and a tip retired by the gesture it teaches stays +// retired, because that person really did use it. +const noticeLedgerRule = 2 + +// forgive un-retires every row the old counting rule spent — retired, and +// shown at least limit times — and reports how many it gave back. A row +// retired short of the count was retired by a gesture and is left alone. +func (l *noticeLedger) forgive(limit int) int { + given := 0 + for id, mark := range l.Seen { + if mark.Retired != "" && mark.Shown >= limit { + l.Seen[id] = noticeMark{} + given++ + } + } + return given +} + // noticeMark is the ledger's word on one notice. type noticeMark struct { // Shown counts the SHOWINGS — turns of a row's rotation, on either box — diff --git a/internal/tui3/notice_test.go b/internal/tui3/notice_test.go index 8cef9d9362..9e027168a0 100644 --- a/internal/tui3/notice_test.go +++ b/internal/tui3/notice_test.go @@ -133,21 +133,24 @@ func TestAHintAgesOutAcrossOrdinaryLaunches(t *testing.T) { // The task tip is the one a first exchange arms highest (notice.go's // table); `/ shows every command` stood here until both feet said it. const hint = "task-in-chat" - // A SHOWING IS A VISIBLE ONE: the row draws after a quiet minute, so each - // launch is a turn ending and then a minute of nothing (chattip_test.go - // proves the clock; here it is turned by hand). - launch := func() *app { + // A SHOWING IS A VISIBLE ONE THAT STOOD: the row draws after a quiet + // minute, so each launch is a turn ending, a minute of nothing, the tip + // standing [noticeReadTime], and then a key — which puts the row away and + // is when the showing is counted (chattip_test.go proves the clock; here + // it is turned by hand). + launch := func() (*app, func(time.Duration)) { a := noticeApp(t, "") a.turn = 1 a.noticeEvent(eventTurnEnded) - quietMinute(a) - return a + return a, quietMinute(a) } for session := 1; session <= noticeShownDefault; session++ { - a := launch() + a, stand := launch() if got := a.notices.current[hintSlotForTest]; got != hint { t.Fatalf("launch %d holds %q in the hint slot, want %q", session, got, hint) } + stand(noticeReadTime) + a.noticeTouched() if got := loadNoticeLedger(noticeLedgerPath("")).shown(hint); got != session { t.Fatalf("after launch %d the ledger on disk counts %d showings", session, got) } @@ -155,7 +158,7 @@ func TestAHintAgesOutAcrossOrdinaryLaunches(t *testing.T) { if got := loadNoticeLedger(noticeLedgerPath("")); !got.retired(hint) { t.Fatalf("the hint is not retired after %d launches: %+v", noticeShownDefault, got) } - if a := launch(); a.notices.current[hintSlotForTest] == hint { + if a, _ := launch(); a.notices.current[hintSlotForTest] == hint { t.Fatalf("a hint shown in %d launches came back in the next one", noticeShownDefault) } } @@ -165,14 +168,16 @@ func TestAHintAgesOutAcrossOrdinaryLaunches(t *testing.T) { const hintSlotForTest = slotHint // quietMinute turns the conversation's clock by hand until the row's tip is -// due (notice.go's [app.noticeIdleBeat]). -func quietMinute(a *app) { +// due (notice.go's [app.noticeIdleBeat]), and hands back the hand that moves +// that clock on, for a test that needs the tip to stand a while. +func quietMinute(a *app) func(time.Duration) { now := time.Date(2026, 9, 22, 12, 0, 0, 0, time.UTC) a.clock = func() time.Time { return now } a.noticeTouched() a.noticeArmIdle() now = now.Add(chatHintIdle) a.noticeIdleBeat(a.notices.idleGen) + return func(d time.Duration) { now = now.Add(d) } } // AND THE NEWS CHANNEL HAS AN OLDER BUILD TO COMPARE AGAINST. It opens on the @@ -276,11 +281,16 @@ func TestTheNoticeLedgerWriteLeavesNoPartialFile(t *testing.T) { func freshBoard() noticeBoard { return newNoticeBoard("", "", true) } +// fixedLimit is a limit lookup answering n for every notice, for the board +// tests that need no table. +func fixedLimit(n int) func(string) int { return func(string) int { return n } } + // A ROTATION, NOT A RANKING: the first eligible tip stands, an event without // an advance keeps it, an advance moves to the next eligible in the table's // order, and the ring comes round. func TestASlotRotatesThroughTheEligibleTipsInTableOrder(t *testing.T) { b := freshBoard() + now := time.Date(2026, 9, 22, 9, 0, 0, 0, time.UTC) cands := []noticeCandidate{ {id: "first", armed: true}, {id: "idle", armed: false}, @@ -290,7 +300,7 @@ func TestASlotRotatesThroughTheEligibleTipsInTableOrder(t *testing.T) { if got := b.pick(slotHint, cands); got != "first" { t.Fatalf("the slot picked %q, want the first eligible", got) } - b.take(slotHint, "first", 6, true) + b.take(slotHint, "first", true, now, fixedLimit(6)) if got := b.pick(slotHint, cands); got != "first" { t.Fatalf("an event without an advance moved the slot to %q", got) } @@ -300,7 +310,7 @@ func TestASlotRotatesThroughTheEligibleTipsInTableOrder(t *testing.T) { if got != want { t.Fatalf("the ring went to %q, want %q", got, want) } - b.take(slotHint, got, 6, true) + b.take(slotHint, got, true, now, fixedLimit(6)) } // The note slot rotates on the same terms; with one candidate it is that one. if got := b.pick(slotNote, []noticeCandidate{{id: "news", armed: true}}); got != "news" { @@ -312,6 +322,7 @@ func TestASlotRotatesThroughTheEligibleTipsInTableOrder(t *testing.T) { // was asked to move — and then takes its turn like every other row. func TestAFreshTipJumpsTheRing(t *testing.T) { b := freshBoard() + now := time.Date(2026, 9, 22, 9, 0, 0, 0, time.UTC) cands := []noticeCandidate{ {id: "compact", armed: false}, {id: "first", armed: true}, @@ -320,12 +331,12 @@ func TestAFreshTipJumpsTheRing(t *testing.T) { if got := b.pick(slotHint, cands); got != "first" { t.Fatalf("the slot picked %q", got) } - b.take(slotHint, "first", 6, true) + b.take(slotHint, "first", true, now, fixedLimit(6)) cands[0] = noticeCandidate{id: "compact", armed: true, fresh: true} if got := b.pick(slotHint, cands); got != "compact" { t.Fatalf("a fresh tip did not jump the ring: %q", got) } - b.take(slotHint, "compact", 6, true) + b.take(slotHint, "compact", true, now, fixedLimit(6)) // No longer fresh: an event keeps it, and an advance walks on from it. cands[0].fresh = false if got := b.pick(slotHint, cands); got != "compact" { @@ -336,40 +347,66 @@ func TestAFreshTipJumpsTheRing(t *testing.T) { t.Fatalf("the ring did not walk on from the fresh tip: %q", got) } // A tip disarming stands down at once, for the next eligible. - b.take(slotHint, "first", 6, true) + b.take(slotHint, "first", true, now, fixedLimit(6)) cands[1].armed = false if got := b.pick(slotHint, cands); got != "second" { t.Fatalf("a disarmed tip did not yield: %q", got) } } -// Every change of hands is a showing, a slot re-decided to the same tip is not, -// and the last allowed showing retires the notice while leaving it up. -func TestEveryChangeOfHandsIsAShowingAndTheLastOneRetires(t *testing.T) { +// A SHOWING IS A TIP THAT STOOD TWENTY SECONDS ON A VISIBLE ROW. A flash on the +// way through is nothing; a tip that stood is counted when it leaves; a tip +// decided while the row could not be seen counts nothing until the row comes +// into view; and the last allowed showing retires the notice. +func TestAShowingIsATipThatStoodLongEnoughToBeRead(t *testing.T) { b := freshBoard() - b.take(slotHint, "tip", 3, true) - b.take(slotHint, "tip", 3, true) - if got := b.ledger.shown("tip"); got != 1 { - t.Fatalf("one standing counted %d showings", got) - } - b.take(slotHint, "", 3, true) - b.take(slotHint, "tip", 3, true) + now := time.Date(2026, 9, 22, 9, 0, 0, 0, time.UTC) + limit := fixedLimit(3) + // A flash: the tip leaves five seconds after it came. + b.take(slotHint, "tip", true, now, limit) + now = now.Add(5 * time.Second) + b.take(slotHint, "", true, now, limit) + if got := b.ledger.shown("tip"); got != 0 { + t.Fatalf("a five-second flash counted %d showings", got) + } + // Re-deciding the same tip is nothing, and standing twenty seconds is one. + b.take(slotHint, "tip", true, now, limit) + b.take(slotHint, "tip", true, now, limit) + now = now.Add(noticeReadTime) + if b.settle(slotHint, now, limit); b.ledger.shown("tip") != 1 { + t.Fatalf("a tip that stood %s counted %d showings, want 1", noticeReadTime, b.ledger.shown("tip")) + } + // A settled tip does not count again until it is seen again. + now = now.Add(time.Minute) + if b.settle(slotHint, now, limit); b.ledger.shown("tip") != 1 { + t.Fatalf("a settled tip counted again: %d", b.ledger.shown("tip")) + } + // Decided while the row is out of sight: no standing until it is visible. + b.take(slotHint, "", false, now, limit) + b.take(slotHint, "tip", false, now, limit) + now = now.Add(time.Hour) + if b.settle(slotHint, now, limit); b.ledger.shown("tip") != 1 { + t.Fatalf("a tip nobody could see counted: %d", b.ledger.shown("tip")) + } + b.visible(slotHint, now) + now = now.Add(noticeReadTime) + b.take(slotHint, "other", true, now, limit) if got := b.ledger.shown("tip"); got != 2 { - t.Fatalf("a tip coming back counted %d showings, want 2", got) + t.Fatalf("the tip counted %d showings after coming into view and standing, want 2", got) } if b.retired("tip") { t.Fatal("a second showing retired the notice") } - // The next surface over the same ledger: the third showing is the last. + // The next surface over the same ledger: the third showing is the last, + // and the slot is cleared as the notice retires. next := newNoticeBoard("", "", true) next.ledger = b.ledger - next.take(slotHome, "tip", 3, true) + next.take(slotHome, "tip", true, now, limit) + now = now.Add(noticeReadTime) + next.settle(slotHome, now, limit) if !next.retired("tip") { t.Fatal("the last allowed showing did not retire the notice") } - if next.current[slotHome] != "tip" { - t.Fatal("the last allowed showing was not shown") - } } // Once retired in a session, a notice may not come back in it even while its @@ -377,7 +414,7 @@ func TestEveryChangeOfHandsIsAShowingAndTheLastOneRetires(t *testing.T) { func TestARetiredNoticeNeverReturnsThisSession(t *testing.T) { b := freshBoard() cands := []noticeCandidate{{id: "tip", armed: true}} - b.take(slotHint, "tip", 3, true) + b.take(slotHint, "tip", true, time.Now(), fixedLimit(3)) b.retire("tip") if b.current[slotHint] != "" { t.Fatal("retiring did not clear the slot") @@ -387,6 +424,80 @@ func TestARetiredNoticeNeverReturnsThisSession(t *testing.T) { } } +// A LEDGER WRITTEN UNDER THE OLD COUNTING RULE IS FORGIVEN ONCE. The tips it +// spent on flashes come back, the ones a gesture retired stay retired, and the +// rule is written down so the next launch forgives nothing. +func TestTheLedgerForgivesWhatTheOldCountingRuleSpent(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, noticeLedgerName) + old := noticeLedger{Build: "abc", Seen: map[string]noticeMark{ + "spent-by-count": {Shown: noticeShownDefault, Retired: "2026-09-22T12:00:00Z"}, + "used": {Shown: 2, Retired: "2026-09-22T12:00:00Z"}, + "still-going": {Shown: 4}, + "spent-and-beyond": {Shown: noticeShownDefault + 2, Retired: "2026-09-22T12:00:00Z"}, + }} + if err := old.write(path); err != nil { + t.Fatal(err) + } + b := newNoticeBoard(path, "abc", true) + if b.retired("spent-by-count") || b.retired("spent-and-beyond") { + t.Fatal("a tip the old rule spent was not forgiven") + } + if b.ledger.shown("spent-by-count") != 0 { + t.Fatalf("a forgiven tip keeps %d showings", b.ledger.shown("spent-by-count")) + } + if !b.retired("used") { + t.Fatal("a tip retired by its gesture was forgiven") + } + if b.ledger.shown("still-going") != 4 { + t.Fatalf("a live tip's count changed to %d", b.ledger.shown("still-going")) + } + written := loadNoticeLedger(path) + if written.Rule != noticeLedgerRule || written.retired("spent-by-count") { + t.Fatalf("the forgiveness was not written down: %+v", written) + } + // And the next launch forgives nothing: a tip spent under the new rule + // stays spent. + b.ledger.Seen["spent-by-count"] = noticeMark{Shown: noticeShownDefault, Retired: "2026-09-22T13:00:00Z"} + b.save() + again := newNoticeBoard(path, "abc", true) + if !again.retired("spent-by-count") { + t.Fatal("a ledger already on the new rule was forgiven again") + } +} + +// THROUGH HOME: a tip that stood on home's row for a bounce is not a showing, +// one that stood twenty seconds is, and the ledger says so when home is left. +func TestABounceThroughHomeIsNotAShowingAndAStandIs(t *testing.T) { + lab := newHomeLab(t) + a := lab.door("") + now := time.Date(2026, 9, 22, 10, 0, 0, 0, time.UTC) + a.clock = func() time.Time { return now } + a.showPage(pageHome) + first := a.notices.current[slotHome] + if first == "" { + t.Fatal("home opened with nothing on its row") + } + now = now.Add(3 * time.Second) + a.closeHome() + if got := a.notices.ledger.shown(first); got != 0 { + t.Fatalf("a three-second bounce through home counted %d showings of %q", got, first) + } + a.showPage(pageHome) + second := a.notices.current[slotHome] + if second == "" || second == first { + t.Fatalf("the second visit holds %q", second) + } + now = now.Add(noticeReadTime + time.Second) + a.closeHome() + if got := a.notices.ledger.shown(second); got != 1 { + t.Fatalf("a tip that stood %s on home counted %d showings, want 1", noticeReadTime, got) + } + if got := a.notices.ledger.shown(first); got != 0 { + t.Fatalf("the bounced tip was counted later: %d", got) + } +} + // ── through the surface ───────────────────────────────────────────────────── // noticeApp is [sheetApp] over a profile directory the caller already has: the @@ -579,6 +690,7 @@ func TestEveryRetireEventIsProvedByItsGesture(t *testing.T) { eventSearchOpened: func(t *testing.T, a *app) { a.slash("/search") }, eventSubharnessOpened: func(t *testing.T, a *app) { a.slash("/subharness") }, eventConnectOpened: func(t *testing.T, a *app) { a.slash("/connect") }, + eventAutonomyAsked: func(t *testing.T, a *app) { a.slash("/autonomy") }, } for _, name := range noticeEvents { if name == eventBoot { diff --git a/internal/tui3/place_home.go b/internal/tui3/place_home.go index 062e836c43..cad9fa707a 100644 --- a/internal/tui3/place_home.go +++ b/internal/tui3/place_home.go @@ -358,18 +358,6 @@ func (placeHome) close(a *app) { a.dropHome() } // body is home's own column, and the pane map beside it: two facts per row, so // the hit is a [homeMark] rather than a line number. func (placeHome) body(a *app, width, room int) []placeRow { - // A FROZEN HOME DRAWS ITS SNAPSHOT (copymode.go's [app.freezeHome]): the - // rows as they stood when ctrl+b was pressed, with the reader's cursor on - // them. Nothing under it is rebuilt, and no row answers the pointer — row - // 14 of a snapshot is not row 14 of the list. - if a.copy.on { - frozen, _ := a.copyRows(width, room) - rows := make([]placeRow, 0, len(frozen)) - for _, r := range frozen { - rows = append(rows, placeRow{text: r.text, hit: homeMark{line: -1, pane: -1}}) - } - return rows - } // AT REST THE BODY IS THE GRID (homegrid.go), and its shape is settled // before it is drawn: the column count, the width and the room all decide // which rows exist — a whisper wraps at its column's width — so any of them From b91a39103755f53a4f75222a14003b7c1183ef3e Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Tue, 22 Sep 2026 13:46:38 -0400 Subject: [PATCH 08/39] =?UTF-8?q?chat:=20copy=20mode=20is=20gone=20?= =?UTF-8?q?=E2=80=94=20ctrl+b=20and=20/copy=20do=20nothing,=20the=20mouse?= =?UTF-8?q?=20copies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The frozen viewport with v, a and y, its status word, its keys row, the /copy command and every gate the surface kept on it are removed, on the owner's judgement that it was no use beside the drag. The clipboard wire stays: ctrl+s hands the pointer over, the sweep copies on release, and both leave through OSC 52 (copymode.go becomes clipboard.go). ctrl+b is bound to nothing in a conversation and stays the caret's left on home. The keys, commands, screen, tasks, sessions, home and what-i-can-do pages say so, and three probes reach them. Co-Authored-By: Claude Fable 5.1 --- internal/manual/chat/commands.md | 24 +- internal/manual/chat/home.md | 4 +- internal/manual/chat/keys.md | 126 ++--- internal/manual/chat/screen.md | 27 +- internal/manual/chat/sessions-and-rewind.md | 6 +- internal/manual/chat/starting-codeaf.md | 2 +- internal/manual/chat/tasks.md | 7 +- internal/manual/chat/what-i-can-do.md | 2 +- internal/manual/chat_test.go | 4 + internal/tui3/app.go | 43 +- internal/tui3/background.go | 2 +- internal/tui3/bargein.go | 2 +- internal/tui3/bottomchrome_test.go | 22 - internal/tui3/bundle_test.go | 229 +-------- internal/tui3/clipboard.go | 195 ++++++++ internal/tui3/commands.go | 18 +- internal/tui3/copymode.go | 506 -------------------- internal/tui3/detach.go | 7 +- internal/tui3/dragspan_test.go | 2 +- internal/tui3/foot.go | 8 +- internal/tui3/gutter_test.go | 2 +- internal/tui3/home.go | 2 +- internal/tui3/homeslash.go | 2 +- internal/tui3/hop.go | 2 +- internal/tui3/hover.go | 4 +- internal/tui3/input.go | 23 +- internal/tui3/jobpage.go | 2 +- internal/tui3/jumpchip.go | 15 +- internal/tui3/markdownwrap_test.go | 42 -- internal/tui3/moneydoor.go | 2 +- internal/tui3/notice.go | 6 +- internal/tui3/notice_test.go | 7 - internal/tui3/payload.go | 2 +- internal/tui3/payload_test.go | 6 +- internal/tui3/place_sessions.go | 2 +- internal/tui3/projectseam.go | 2 +- internal/tui3/question.go | 2 +- internal/tui3/render.go | 14 +- internal/tui3/rewind.go | 2 +- internal/tui3/rewindsheet.go | 2 +- internal/tui3/rewindsheet_test.go | 1 - internal/tui3/room.go | 49 +- internal/tui3/roomscroll_test.go | 37 -- internal/tui3/roomstatus_test.go | 5 - internal/tui3/slotfilter_test.go | 18 +- internal/tui3/spellout.go | 2 +- internal/tui3/spellout_test.go | 11 +- internal/tui3/steer.go | 4 +- internal/tui3/steer_test.go | 27 -- internal/tui3/stop.go | 4 +- internal/tui3/task.go | 2 +- internal/tui3/taskstrip.go | 4 +- internal/tui3/taskview.go | 4 +- internal/tui3/tui3_test.go | 2 +- internal/tui3/view.go | 3 - 55 files changed, 343 insertions(+), 1207 deletions(-) create mode 100644 internal/tui3/clipboard.go delete mode 100644 internal/tui3/copymode.go diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index a6cae6acca..b4cef9c109 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -45,7 +45,7 @@ A partial path such as `/tmp` is still indistinguishable from an unknown command it shows `no commands match` until another slash or path punctuation makes the intent clear. Pasting a complete path never needs those intermediate states. -Panels such as settings, the model picker, resume and copy mode keep their own keyboard +Panels such as settings, the model picker and resume keep their own keyboard handling; typing `/` there does not open this composer list. ## Slash commands are drawn as chips @@ -200,7 +200,6 @@ Canonical word, the other words it answers to, its argument form, and what it do | `/debug` | — | — | keeps the full record of **this conversation** from here on, and says which folder it goes to | | `/update` | `/upgrade` | — | installs the newest stable release and restarts this conversation on it | | `/update` | `/upgrade` | `` | installs that channel's newest release or one exact tag, then restarts this conversation on it | -| `/copy` | — | — | enters copy mode (also ctrl+b) | | `/select` | — | — | hands the pointer back to the terminal (also ctrl+s) | | `/export` | `/save` | — | writes the whole conversation to a file | | `/export` | `/save` | `` | …and writes it there; tab completes the path | @@ -374,18 +373,17 @@ second `enter` on that same point does the rewind, `esc` clears the search and t closes the page. The head reads `⟲ rewind — pick where the conversation goes back to` and the foot reads `⟲ drops 2 turns — everything below the pick is let go`. -**Be warned: `/rewind` silently does nothing in six states.** No message, no page, +**Be warned: `/rewind` silently does nothing in five states.** No message, no page, nothing at all happens when: - the rewind timeline is already open, - the inline rewind mode is already on, -- copy mode is on, - a task room is open, - the settings panel is open, - the task rail is full. Each of those already owns the frame or the row the rewind needs, so the command is -dropped rather than half-drawn. If `/rewind` seems to do nothing, one of those six is why. +dropped rather than half-drawn. If `/rewind` seems to do nothing, one of those five is why. With no rewind points, or no agent that can rewind, it does answer, exactly: @@ -393,17 +391,13 @@ With no rewind points, or no agent that can rewind, it does answer, exactly: nothing to rewind ``` -## /copy — read the conversation back and copy from it +## /copy — what happened to /copy, there is no /copy any more, copy mode was removed, how do I copy from the conversation -`/copy` freezes the visible conversation and enters copy mode. It is the same thing -ctrl+b does. In copy mode ↑↓ move, `v` marks, `a` takes the block, `y` yanks. - -**`/copy` silently does nothing in two states**, with no message either way: - -- copy mode is already on, -- there are no visible rows to freeze. - -If you type `/copy` and the screen does not change, one of those two is why. +`/copy` is not a command. It entered copy mode — the frozen conversation `ctrl+b` also +opened, read with ↑↓, `v`, `a` and `y` — until 2026-09-22, when copy mode was removed +whole. Typing it now answers `there is no command called /copy · / lists them`. To copy +text out of the conversation, drag across it with the mouse, or press `ctrl+s` and let +your terminal select (the keys page, *Selecting text with your mouse*). ## /select — drag to select with your mouse diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index 9ce603b2de..da622423a7 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -1086,7 +1086,7 @@ it redraws it from its own transcript, and these come back with it: - the transcript, the task column, the meters, the model, the title and any card still waiting for an answer — all of which are read back from the conversation itself. -**These are forgotten:** copy mode, a rewind you were part way through, the settings panel, +**These are forgotten:** a rewind you were part way through, the settings panel, the model picker, `/history`, the deliverables shelf, a task column focus. Each is something you are in the *middle* of, or a door onto something the whole terminal shares. @@ -1260,7 +1260,7 @@ one behind your back. This is every fate, in the words the drop-up draws them in | **`opens the page`** | `/settings` `/set` `/config` · `/home` · `/search` · `/spend` · `/standing` · `/memory` `/memories` · `/history` · `/task` (bare) | A place replaces a place, exactly as before. | | **`this list is /resume`** | `/resume` `/sessions` | Says `this list is /resume · enter opens a row` — home *is* that list. | | **`onto home's tray`** | `/attach ` | The file — or picture — rides on home's own tray into the conversation you open next. Home says `attached · notes.md · rides with the next conversation`. A bare `/attach` opens the browser aimed at the next conversation's folder, and a file chosen there lands on the tray. | -| **`opens a conversation here first`** | `/files` · `/manual` · `/crew` (bare) · `/permissions` `/perms` · `/connect` · `/harness` · `/subharness` · `/copy` · `/select` · `/rewind` `/undo` `/back` · `/compact` · `/export` `/save` · `/standing ` · `/task ` | Opens a conversation at the target — the folder and model on the rule above the box — then runs there. Home closes, exactly as `enter` closes it. `/manual` is on this road since 2026-09-22: it is a question put to the model, so it needs a conversation to be asked in. | +| **`opens a conversation here first`** | `/files` · `/manual` · `/crew` (bare) · `/permissions` `/perms` · `/connect` · `/harness` · `/subharness` · `/select` · `/rewind` `/undo` `/back` · `/compact` · `/export` `/save` · `/standing ` · `/task ` | Opens a conversation at the target — the folder and model on the rule above the box — then runs there. Home closes, exactly as `enter` closes it. `/manual` is on this road since 2026-09-22: it is a question put to the model, so it needs a conversation to be asked in. | | **`answers here`** | `/help` · `/status` · `/cost` · `/cache` · `/budget` · `/crew ` · `/debug` · `/stop` · `/remember` · `/forget` · a word nobody defined | Answers with a note, and the first line of that note is put on home's own line under the box. `there is no command called /pricing · / lists them` is now something you can read. | | **`runs on the conversation behind home`** | `/land` · `/land ` · `/workspace ` | Acts on the conversation this window is holding behind the screen — not on the one `enter` would open — and its answer is echoed onto home's line. | | **`a fresh conversation behind home`** | `/new` `/clear` `/clean` `/reset` | Replaces the conversation behind the screen and says `started a fresh conversation behind home`. It is not the same act as `enter`, which opens a conversation at the target. | diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index 10a1ae3f25..cf41ace05a 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -585,7 +585,7 @@ key arrives as ordinary `enter` and the message steers instead. | Chord | What it does | |---|---| | `ctrl+o` | Selected landed card: open its output. Selected proposal: open its brief. Inside a task's page: open or fold the long instruction at the top. Otherwise: open or fold the live caption's tool rows; before a live caption exists, open or fold the `N earlier tool calls` fallback. It never opens a `▸ worked` chip — that is `ctrl+e` | -| `ctrl+b` | Enter copy mode — freeze the view so you can read and copy. Not on home, where it moves the caret | +| `ctrl+b` | Nothing in a conversation — copy mode was removed on 2026-09-22. On home it moves the caret one cell left | | `ctrl+s` | Hand the pointer to your terminal so you can drag-select. Toggles; any other key takes it back | | `ctrl+,` | Open the settings panel | | `alt+e` | Walk this conversation's thinking rung one step: auto → low → medium → high → xhigh → max, and back to auto. Works with a sentence half typed. On home and every other place it walks the rung of the **next** conversation instead — the effort word after the model’s colon on home’s seam | @@ -930,9 +930,6 @@ the brackets can submit, interrupt, or answer a question. `ctrl+c` is the one exception and still works — it leaves codeaf without closing the bracket. A bracket that goes quiet for 2 seconds is treated as abandoned, flushed, and the keyboard handed back. -A paste while copy mode is up is **declined** — nothing happens, and your clipboard -still holds the text. - ## Make my prompt better — spell it out with `ctrl+r` Type what you want and press **`ctrl+r`**. codeaf reads the sentence sitting in the box @@ -2415,8 +2412,7 @@ back (*The empty screen* page). **With a room open:** `esc` leaves the room, though a history recall walk is cancelled first · `enter` steers the node (see *What steering a task looks like on its -page* below) · `ctrl+b` freezes the room's own rows for -copying, not the conversation's · `pgup`/`pgdown` page · `up`/`down` walk your history, +page* below) · `pgup`/`pgdown` page · `up`/`down` walk your history, and scroll the page one row only when there is no history to walk. `left` is deliberately **not** taken here — it falls through to the message box's back-navigation. @@ -2567,7 +2563,7 @@ and failing, and its letter is absent with it. **They are held to the same rule `x` is.** The card must be the **selected** one — walk to it with `↑`/`↓`, which steps through tool calls, proposals and landed cards — and the -message box must be **empty**, with no panel, picker or copy mode up. A letter typed into a +message box must be **empty**, with no panel or picker up. A letter typed into a sentence stays a letter, always. Once answered the letters go away and the receipt every question leaves takes their place, @@ -2670,8 +2666,7 @@ terminals open one and what is deliberately not linked. **A click on empty space does nothing, anywhere** — there is no empty-space gesture on this surface, and that includes inside a room: a press on a blank row of a task's page is not the way out and never closes it. The way out of a room is `esc`, `←`, or a press -on the pinned header that names them. **A click in copy mode acts on nothing**, because -the rows there are a frozen snapshot. +on the pinned header that names them. **Hover** lights whatever the pointer is on, at the size of the thing rather than the size of its row: a row that is one target — a tool call, a roster row, a parked message, a @@ -2680,14 +2675,14 @@ line — a strip chip, a picture on the tray, one answer of a card, a task refer reply — lights only its own cells, leaving its neighbours dark. Anything that answers to nothing does not react. On home it does one thing more: the preview on the right becomes the row you are pointing at, and returns to the cursor's row when you point somewhere else -(see the home page). There is no hover in copy mode, on the linear/screen-reader tier, or -in the phone tool sheet. The screen page says what lights, under "When a row brightens +(see the home page). There is no hover on the linear/screen-reader tier, or in the phone +tool sheet. The screen page says what lights, under "When a row brightens under the pointer". ## Scrolling The wheel moves three rows per notch, on whichever surface owns the frame. It is -routed to copy mode, then the settings panel, then the task page, then home, then the +routed to the settings panel, then the task page, then home, then the rewind timeline, then the status deck, then the phone tool sheet, then the fullscreen roster, then **the task column** when the pointer is over it, then an open room, and otherwise the conversation. @@ -2712,8 +2707,8 @@ away from the live edge. It is drawn into the first row of the frame's existing breathing gap, so it never takes a row of its own; on a window too short to have a gap it is not drawn at all, though `ctrl+l` still works. It is dim normally and accent-coloured under the pointer. Clicking it, or pressing `ctrl+l`, rejoins the live -edge — the room's edge if a room is open. It is not shown while copy mode, a room, or -the fullscreen roster is up. +edge — the room's edge if a room is open. It is not shown while a room or the +fullscreen roster is up. ## Selecting text with your mouse — drag to copy, select a word with the mouse, why did copying take the whole line instead of the words I dragged over @@ -2721,8 +2716,8 @@ the fullscreen roster is up. down, and the cells between them highlight — from where you pressed to the end of that line, every line between in full, and the last line up to where you are, exactly as your terminal would select it. The moment you release, that text is **on your -clipboard** — stripped of colours and the drawn left rails, exactly as copy mode -strips a yank. There is nothing further to press: no ctrl+c, no key at all — +clipboard** — stripped of colours and the drawn left rails, so a paste carries only +the words. There is nothing further to press: no ctrl+c, no key at all — releasing the button IS the copy. The highlight stays lit for the few seconds the status line says what landed — `copied · 14 chars` for a span inside one line, `copied · 3 lines` across several — so you can see exactly what you got. The write @@ -2753,8 +2748,8 @@ place. The one difference is that a selection there is live — you can type ove so it stays lit until you move the caret rather than fading with the status line. See "how do I select text in the message box" above. -The sweep is drawn in **the same background copy mode's selection wears** — the strongest -of the three this screen draws, a shade above the one under the pointer. It is the same +The sweep is drawn in **the selection background** — the strongest of the three this +screen draws, a shade above the one under the pointer. It is the same claim ("these rows are what a copy would take"), so it is the same paint; it used to be drawn at the pointer's own quieter step, which said a sweep in progress was a shadow rather than a selection. @@ -2830,8 +2825,7 @@ your mouse" above. **There is nothing under the pointer.** A click on empty space does nothing anywhere on this surface, including the gap between two words of the tab bar and the blank rows of a -task's page. A click in copy mode acts on nothing at all, because those rows are a frozen -snapshot. +task's page. **The terminal is too narrow for the word you are aiming at.** The tab bar gives up words as the frame narrows, and at its narrowest it carries only the place you are standing in — @@ -2841,79 +2835,23 @@ and `alt+1`…`alt+7` still go everywhere. **A file path is your terminal's click, not codeaf's** — usually **cmd+click** (ctrl+click on Linux). If a plain click on a path does nothing, that is why. -## Copy mode: taking text out of the conversation — ctrl+b, freeze the screen, esc to leave, and ctrl+b on home does nothing of the kind - -`ctrl+b` freezes the view and hands the keyboard to a reader, so you can pull text out -of a surface that runs in the alternate screen where your terminal's own selection is -gone. The `/copy` command does the same. Inside a room, `ctrl+b` freezes **the room's -rows** rather than the conversation's. - -**On home `ctrl+b` is not copy mode.** It moves the caret in home's box one cell to the -left, as `←` does, and home's rows are never frozen. Nothing can be copied off the home -screen this way: a title or a path on home is on a row `enter` opens, and inside that -conversation the rows can be frozen. (For one build on 2026-09-22 the key froze home's -own screen; it was taken out the same day.) - -Freezing looks like nothing when nothing is moving — the rows stay where they are on -purpose. What tells you the freeze is on is the keys row, which reads exactly -`v select · a block · y yank · esc`, the highlighted cursor row, and the status word -`COPY`. `esc` always leaves. - -| Chord | What it does | -|---|---| -| `up`/`k`, `down`/`j` | Move | -| `pgup`, `pgdown` | Page | -| `home`, `end` | Jump to top or bottom | -| `v` | Drop or lift the mark | -| `a` | Take the block under the cursor | -| `y` | Yank the selection | -| `esc`, `ctrl+b`, `q` | Leave | - -Copy mode takes **every other key too**, and does nothing with them. - -`a` asks the narrower question first — a run of fenced **code** rows. Press `a` again -to widen to the whole answer around it. A blank row belongs to nothing, and `a` there -does nothing. - -`y` **stays** in copy mode and lifts the mark, so a second `y` cannot copy the same -span by accident. - -The status line reads exactly `COPY`, or `COPY · N lines` when more than one line is -selected. The row under the box reads `v select · a block · y yank · esc`. - -## What copy mode copies, and what it refuses - -Freezing snapshots the rows both painted and plain. The conversation underneath keeps -streaming, and none of it moves the rows you are reading. Leaving rejoins the live -edge — the room's edge if a room was frozen. - -**What comes out** is the plain text with the left rail lifted — the stem under an -expanded tool call, the hairline beside a fenced block or a blockquote — along with -the indent in front of it and any trailing padding. The one-off marks `› ` and `· ` -are deliberately **kept**, because they say who was speaking. - -**How it reaches your clipboard:** OSC 52, written in band, so it works over ssh and -inside a container with no display. When `TERM` starts with `screen` or `tmux` it is -wrapped in tmux's DCS passthrough with every ESC doubled. It uses the clipboard -selection, not the primary one. - -**Refusals while copy mode is up:** +## Copy mode is gone — ctrl+b does nothing, there is no /copy, how do I copy text out of the conversation -- A click does nothing. -- A paste is declined, and your clipboard keeps the text. -- There is no hover. -- The selection highlight is a background, and a terminal below ANSI256 gets no - highlight at all. Read the span off the `COPY · N lines` count instead. It is the - strongest of the three backgrounds this screen draws — a shade above the one under - the pointer and the one under a chosen row — because a selection is held open and - runs across many lines at once, and you are looking for both of its ends. +**There is no copy mode.** Until 2026-09-22 `ctrl+b` (and `/copy`) froze the conversation +and handed the keyboard to a reader — `v` marked, `a` took a block, `y` yanked, `esc` +left, and the status line read `COPY`. It was removed whole that day, on the judgement +that it was no use beside the mouse. `ctrl+b` is bound to **nothing** in a conversation +and in a task's room; on home it still moves the caret in the box one cell left, as +`←` does. `/copy` is not a command, and typing it answers +`there is no command called /copy · / lists them`. -**An image drawn in an expansion copies as what it is on screen** — rows of `▀`, with -the colour stripped, which is no use to anybody. Take the dim line under it instead: -it is the picture's whole absolute path. It is also a link you can click, the same as -every other real file path on this screen — see "click a file path to open it" on the -"what is on the screen" page. What copy mode gives you is the plain path, with the link -stripped off it. +**To copy text out of the conversation, use the mouse.** Drag across the rows and the +text is on your clipboard the moment you release — see *Selecting text with your mouse* +below. If you would rather your terminal did the selecting, `ctrl+s` hands it the pointer +until your next key. Both leave through the same in-band clipboard write, so they work +over ssh and inside tmux; a paste from either carries the words with the drawn rails +lifted, and a code block comes out as its source without the hairline or the `↳` wrap +mark. Inside a room the drag works the same way over the room's own rows. ## Chords that mean more than one thing @@ -2971,8 +2909,8 @@ sentence in the box and go on typing into the same words. Two more chords surprise people: -- **`ctrl+b` is copy mode, not emacs "left".** The alternate screen took your - terminal's selection away, and copy mode is what buys it back. +- **`ctrl+b` does nothing.** It was copy mode until 2026-09-22 and is bound to nothing + now; copying is the mouse's — drag, or `ctrl+s`. - **`ctrl+e` means two things** depending on whether the box is empty: end of line when there is text, open the most recent thinking block when there is not. - **`ctrl+w` closes a tab wherever you press it**: in a conversation the tab in front, @@ -3096,7 +3034,7 @@ been running longest**, which is the one you are waiting on. While a command can be kept, this meaning takes precedence over hiding or restoring the task column, so the column stays where it was. With no such command the key belongs to -the column as described above. `ctrl+b` is copy mode and `ctrl+c` interrupts; neither changes. +the column as described above. `ctrl+c` interrupts and does not change. ## When a settings change lands diff --git a/internal/manual/chat/screen.md b/internal/manual/chat/screen.md index 5164439c4e..d3224a7ba3 100644 --- a/internal/manual/chat/screen.md +++ b/internal/manual/chat/screen.md @@ -559,7 +559,7 @@ background band. It was right-aligned until 2026-09-09, and out there beside the column it was the one thing on the frame nobody saw. It is not drawn at all when there is no gap row (a short window), when the label is -wider than the frame, in copy mode, while a room is open, or while the fullscreen +wider than the frame, while a room is open, or while the fullscreen roster is up. A room keeps its own edge: `ctrl+l` inside a room scrolls the room, not the conversation. @@ -674,8 +674,8 @@ answer to being scrolled away: it offers the way back rather than taking it. Pre `ctrl+l`, clicking the chip, or scrolling down to the bottom yourself re-arms following, and from then on new output keeps you at the edge again. -Three things do deliberately put you back at the bottom, because in each you asked for -it: sending a message, queueing one with `ctrl+q`, and leaving copy mode. +Two things do deliberately put you back at the bottom, because in each you asked for +it: sending a message, and queueing one with `ctrl+q`. ## The line above the message box (the legend) — the model, the machine in brackets after it, and why the conversation's name is not on it @@ -884,7 +884,7 @@ a mark means: at the start or a live `/standing`, `/orders`, or `/task` tag later in the draft. Help rows chip their leading command too. Nothing else borrows the mark, so it never highlights a slash word the send path will ignore. -- **A key chord is brighter ink and never a background.** `ctrl+b`, `esc`, `↑↓` step up a +- **A key chord is brighter ink and never a background.** `ctrl+s`, `esc`, `↑↓` step up a tier; they do not get a chip. - **Nothing here is ever drawn in the accent.** The accent marks the one live or chosen thing on a screen — your own `›`, the rail — and a line that appears and scrolls away is @@ -1149,7 +1149,7 @@ Four things about it: have been with no such rule. - **The row you are on is never faded**, wherever it has been scrolled to — including when it is the very last row before the fold. Neither is a row under the mouse. -- **Nothing you are reading fades.** The conversation and copy mode are untouched: a +- **Nothing you are reading fades.** The conversation is untouched: a transcript is read line by line and every line of it is the content, not context. - **Rows never alternate light and dark.** codeaf draws no striped lists anywhere. Rows are told apart by spacing, and groups inside a list by a blank line — never by a rule, @@ -1231,11 +1231,8 @@ words: | `waiting · your call` | an approval, standing or saved-program question requires an answer, or a task proposal has no countdown | the question hue, bold | | `stopping · detaching in 7s` | you pressed `ctrl+c` and the turn has not finished letting go yet; the count is what is left of the 10-second bound before codeaf detaches | dim | | `interrupted` | the last turn was stopped by hand and is over | the bad hue | -| `COPY` or `COPY · 12 lines` | copy mode | accent | `stopping` outranks `waiting · your call`, and `waiting · your call` outranks `working`. -Copy mode outranks everything, because it is the only state about the keyboard rather -than about the turn. **A door at rest whose work outlived its turn is not `idle`.** Handing a task out ends your turn, and the node it started works on for minutes with nothing happening in the @@ -1794,8 +1791,8 @@ a source line. The marker sits outside the code plane, so it can never be mistak something the code said. Breaks prefer a space in the back half of the row and go mid-token when there is none — a 40-cell URL in a 30-cell column has no break in it. -Copying takes the block whole: `a` in copy mode selects the run of code rows around the -cursor, wrapped rows included, and the paste carries neither the hairline nor the `↳`. +A drag across the block copies its source whole, wrapped rows included, and the paste +carries neither the hairline nor the `↳`. This used to be true only under 60 columns. Above it a long line was **cut** — with an ellipsis at some widths and with nothing at all at others — so the same answer was whole @@ -1955,7 +1952,7 @@ below is drawn as plain text on purpose: ## Copying a path, and why a reply cannot make its own link -**What you copy is the plain path.** Copy mode (`ctrl+b`, or `/copy`) and `/export` strip the +**What you copy is the plain path.** A mouse drag and `/export` strip the escape sequences, so a path leaves this conversation as the characters you can read, and an exported `.md` has no terminal machinery in it. Your terminal's own select-and-copy takes the visible characters too. @@ -2008,7 +2005,7 @@ When there is no offer: An **open** table keeps its foot at every width, because the foot is the only way back from a choice you made. -Copy mode yanks the rendered rows, so opening a table is the only way to put its real +A drag copies the rendered rows, so opening a table is the only way to put its real content on the clipboard. A closed one offers the ellipses you can already see. ## What opening a table actually does @@ -2835,8 +2832,8 @@ Four rungs, detected once from what your terminal says it can do: tint. - **NoColor** — no escape sequences at all, weight included. -Backgrounds — the hover band, the selection band, the stronger band under a copy-mode -or drag selection, and the chip behind a recognized slash command — are drawn only at +Backgrounds — the hover band, the selection band, the stronger band under a drag +selection, and the chip behind a recognized slash command — are drawn only at ANSI256 and above. There is no weight that means "this row", so a slash command falls back to bold and a hovered row to nothing. @@ -3276,7 +3273,7 @@ repaints in colours measured against your real background rather than an assumed Four things are re-aimed when it lands: - **The three background bands** — the row under the pointer, the chosen row, and a - copy-mode selection — are built out of your own background colour, moved away from + drag selection — are built out of your own background colour, moved away from itself by a fixed amount. They inherit your terminal's tint, and on a 256-colour terminal they still land on greys, never on a hue. - **The reading tiers** — ink, muted, dim — are checked against the real background and diff --git a/internal/manual/chat/sessions-and-rewind.md b/internal/manual/chat/sessions-and-rewind.md index 40777ee384..49fe92655d 100644 --- a/internal/manual/chat/sessions-and-rewind.md +++ b/internal/manual/chat/sessions-and-rewind.md @@ -165,8 +165,8 @@ Files, commands and git state are untouched by all of this. Only the conversatio when neither tier can open at all — the session has no rewind ability, or there is no legal place to cut. `/rewind` will not raise an empty timeline. -**`/rewind` opens nothing at all** in six states, silently: the timeline is already open, the -inline mode is already on, copy mode is on, a task room is open, the settings panel is open, +**`/rewind` opens nothing at all** in five states, silently: the timeline is already open, the +inline mode is already on, a task room is open, the settings panel is open, or the task rail is full. The commands page lists them. **Repeated Escape never enters rewind.** It dismisses the current layer and eventually @@ -800,7 +800,7 @@ not several terminals. These come back with it: left. These are forgotten, and each is something you were in the *middle* of or a door onto -something the whole terminal shares: copy mode, an inline rewind or an open rewind timeline, +something the whole terminal shares: an inline rewind or an open rewind timeline, the expand sheet, the deliverables shelf, every picker and panel, the settings panel, the status deck, the task page, and the task column's focus. diff --git a/internal/manual/chat/starting-codeaf.md b/internal/manual/chat/starting-codeaf.md index 2b323a93bc..7035dcaa09 100644 --- a/internal/manual/chat/starting-codeaf.md +++ b/internal/manual/chat/starting-codeaf.md @@ -771,7 +771,7 @@ This is not the record of what codeaf sent the model: that is a separate file, r codeaf ships with this manual compiled into it, and it reads it with a tool called `manual` rather than answering about itself from memory. So "what can you -do?", "what does ctrl+b do?", "can you read a PDF?" and "why did you just ask me +do?", "what does ctrl+s do?", "can you read a PDF?" and "why did you just ask me that?" are all fair questions to type straight into the conversation. If the manual has nothing on something, that usually means codeaf does not do it, and it will tell you so instead of inventing an answer. diff --git a/internal/manual/chat/tasks.md b/internal/manual/chat/tasks.md index bfbd8c8a7a..642054b6fa 100644 --- a/internal/manual/chat/tasks.md +++ b/internal/manual/chat/tasks.md @@ -3328,7 +3328,6 @@ local conversation the same page tails that log live. | legend hint | `ctrl+c interrupt` while a turn runs | `x stop` while there is work to stop, `↑↓ history` mid-walk, nothing otherwise | | the model on the status row | the conversation's model | `task ` | | clicking that model | opens the picker and switches the conversation | opens the picker and switches **that task**, from its next request — and does nothing at all once the task has landed | -| `ctrl+b` | freezes the transcript | freezes the room's own rows | | scroll position | the conversation's | the room's own, kept separately | | attachments | the tray sends pictures | a room's box sends words only | | proposals | drawn as cards | never — a task's own pieces start without asking you | @@ -3532,9 +3531,7 @@ see *A task's room after a restart*. `pgup`/`pgdown` scroll a page, the mouse wheel scrolls, and reaching the bottom re-sticks to the live edge. `↑`/`↓` walk your history first and only scroll a line when there is no -history to walk — see *Typing in a task's room*. `ctrl+b` freezes the room's rows for -copying — one known wrinkle: leaving copy mode rejoins the conversation's live edge, so -freezing a room while the conversation was scrolled up loses that scroll. +history to walk — see *Typing in a task's room*. The task's elapsed clock freezes while you stand in its room. That number exists to ask whether you should go and look; being there is the answer. Nothing is stopped, only @@ -4510,7 +4507,7 @@ What else you can do yourself, on a task that is running: | walk into it | click it, `enter` on it, or `→` over an empty box | | talk to it | `enter` on a sentence in its room | | read its whole transcript | its room | -| copy text out of it | `ctrl+b` in its room | +| copy text out of it | drag across its rows with the mouse, in its room | | refer to it in conversation | `@` | | leave it | `esc`, `←`, or `←←` — the work keeps running | | stop it | `x`, or `Stop` on its room's facts row — one confirmation card, always | diff --git a/internal/manual/chat/what-i-can-do.md b/internal/manual/chat/what-i-can-do.md index 96293c690e..3f00f9779f 100644 --- a/internal/manual/chat/what-i-can-do.md +++ b/internal/manual/chat/what-i-can-do.md @@ -66,7 +66,7 @@ disk?" below for what comes back and what it costs. Over `--host`, `read`, `write`, `edit` and `ls` run on the other machine, inside the workspace shown for the session. A path in a task brief is read there too. A path the model names in its reply can be opened here: codeaf confirms it on the far disk and -fetches it through a short-lived local file door. Copy mode, `ctrl+s`, mouse drag-copy +fetches it through a short-lived local file door. `ctrl+s`, mouse drag-copy and `m puts it in your message` only copy or compose words on this screen, so they work the same way over a connection and do not move a file. diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index 4653ea85f8..0a2f192c5f 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -546,6 +546,10 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { // nothing, and by the one who wants a title off the list. {"ctrl+b on home does nothing", "keys"}, {"can I copy text off the home screen", "keys"}, + // Copy mode went on 2026-09-22; these are the person who remembers it. + {"how do I copy text out of the conversation", "keys"}, + {"is there a copy mode", "keys"}, + {"what happened to /copy", "commands"}, // The spell-it-out gesture, asked the three ways people meet it: wanting // it, seeing the hint and not knowing what it is, and being unhappy about // what came back. diff --git a/internal/tui3/app.go b/internal/tui3/app.go index 1badf4d08d..19a282e6ee 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -2421,9 +2421,6 @@ type app struct { // all; closed, it costs the frame nothing. subPage subPage - // copy is the frozen viewport a person reads and yanks out of (copymode.go). - // Closed, it costs the frame nothing. - copy copyMode // rew is the rewind mode: the cut line through the transcript, the points it // can sit on, and the draft it is holding (rewind.go). Closed, it costs the // frame nothing. @@ -2825,7 +2822,6 @@ func newApp(ctx context.Context, opts Options) *app { // the memo for any of them (models.go's [app.learnModelLists]). a.learnModelLists() a.prepareModelServices() - a.copy.mark = -1 // AND THE REDUCER IS BUILT WITH WHAT THIS PAGE IS, which is the whole of the // difference between a chat's transcript and any other (feed.go states the // law the hooks exist to keep). It is built here and not in the literal above @@ -3626,17 +3622,6 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { } return a, nil } - // COPY MODE OWNS THE WHEEL while it is up, because the viewport it froze - // is the thing the wheel would otherwise move (copymode.go). - if a.copy.on { - switch msg.Mouse().Button { - case tea.MouseWheelUp: - a.copyScroll(-3) - case tea.MouseWheelDown: - a.copyScroll(3) - } - return a, nil - } if a.questionDialogWheel(msg) { return a, nil } @@ -3835,11 +3820,8 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { if a.pasteEdit.open { return a, nil } - if a.copy.on || a.setup.open { - // A click in copy mode acts on nothing: the rows under the pointer are - // a FROZEN snapshot, and expanding a call in it would be expanding a - // row that is no longer where the conversation says it is. The setup - // screen is the same for the pointer's own reason: it is three + if a.setup.open { + // A CLICK THROUGH THE SETUP SCREEN LANDS ON NOTHING: it is three // keystrokes, and a press through it would land on a frame that is // not being drawn (firstrun.go). return a, nil @@ -4218,10 +4200,9 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { // pointer crossing the window sends one per cell — so [app.setHover] // repaints only when the row under it actually changed (hover.go). // - // Two surfaces have no hover at all and drop it here rather than paying - // for a hit-test per cell: the frozen viewport (nothing under the pointer - // is actionable) and the linear tier (there is no pointer). - if a.copy.on || a.linear { + // The linear tier has no hover at all — there is no pointer — and drops + // it here rather than paying for a hit-test per cell. + if a.linear { return a, nil } // A MOVE WITH THE LEFT BUTTON DOWN IS THE SWEEP, read before every hover: @@ -6738,7 +6719,7 @@ func (a *app) linkHoverAt(x int, r row) int { // the mouse turned off (config's ui.mouse): /model with no argument opens the // same picker, and the help sheet says so. func (a *app) statusPress(x, y int) bool { - if a.copy.on || a.at(pageSettings) || a.pick.open { + if a.at(pageSettings) || a.pick.open { return false } // THE ROW IS RESOLVED BEFORE THE COLUMN, and that order is load-bearing: @@ -6895,10 +6876,6 @@ func (a *app) slash(line string) tea.Cmd { // (budget.go). return a.budget(rest) - case "copy": - a.enterCopy() - return nil - case "select": // It ANSWERS when there is nothing to hand over, because this one was // typed out on purpose: silence after a deliberate command reads as a @@ -8187,14 +8164,6 @@ func (a *app) paste(text string) tea.Cmd { if text == "" { return nil } - // COPY MODE IS A READER, and it is modal for the clipboard exactly as it is - // for the keyboard (copymode.go): the box a paste would land in is off - // screen behind a frozen viewport, so the text would go somewhere nobody can - // see it. The clipboard still holds it, which is the difference between - // declining a paste and losing one. - if a.copy.on { - return nil - } // A PASTE IS SOMEBODY STARTING WORK, so it dismisses the welcome box on the // same terms every other input does (welcome.go): everything puts the box // away except the two keys that walk its list, and a paste is not one of diff --git a/internal/tui3/background.go b/internal/tui3/background.go index f15f7ef0de..0509650154 100644 --- a/internal/tui3/background.go +++ b/internal/tui3/background.go @@ -17,7 +17,7 @@ package tui3 // // ── THE KEY, AND WHY THIS ONE ── // -// ctrl+b is copy mode and ctrl+c is the interrupt, and neither is for sale. ctrl+g +// ctrl+c is the interrupt and is not for sale. ctrl+g // already closes and restores the task column; while a foreground command can // be kept, this reading wins, and with none the column keeps the key. It is // plain BEL so every terminal on every platform delivers it, and it needs no diff --git a/internal/tui3/bargein.go b/internal/tui3/bargein.go index 4a93401374..eb57483b11 100644 --- a/internal/tui3/bargein.go +++ b/internal/tui3/bargein.go @@ -123,7 +123,7 @@ func (a *app) bargeOffered() bool { // outright, and the rail holds it while the roster is up. Every one of these // is read above the plain switch in [app.key], so the guard is here for the // HINT's sake as much as the key's. - return !a.roomOpen() && !a.copy.on && !a.rew.on && !a.railHold + return !a.roomOpen() && !a.rew.on && !a.railHold } // bargeIn is the chord: the draft goes, and the turn stops. diff --git a/internal/tui3/bottomchrome_test.go b/internal/tui3/bottomchrome_test.go index e8d4ca02c3..4d340ff42d 100644 --- a/internal/tui3/bottomchrome_test.go +++ b/internal/tui3/bottomchrome_test.go @@ -242,28 +242,6 @@ func TestTheJumpKeyReturnsToTheLiveEdgeWithADraftInTheBox(t *testing.T) { } } -// A FROZEN VIEWPORT MANAGES ITS OWN EDGE (copymode.go), so the chip stays off -// while it is up rather than offering to scroll a snapshot. -func TestTheJumpChipStaysOffInCopyMode(t *testing.T) { - a := scrolledApp(t, 24) - a.scroll(-6) - if !a.jumpShowing() { - t.Fatal("the chip was never up to begin with") - } - a.enterCopy() - if a.jumpShowing() { - t.Fatal("the chip is up over a frozen viewport") - } - if got := chipAtRow(strings.Split(frame(a), "\n")); got >= 0 { - t.Fatalf("copy mode drew the chip on row %d", got) - } - // Leaving copy mode rejoins the edge on its own, so the chip stays off. - a.exitCopy() - if a.jumpShowing() { - t.Fatal("thawing left the reader off the live edge") - } -} - // THE POINTER HAS TO BE ON THE CHIP, not merely on its row: it is the one target // on this surface narrower than the line it is drawn on. func TestTheJumpChipBrightensUnderThePointerAndNowhereElse(t *testing.T) { diff --git a/internal/tui3/bundle_test.go b/internal/tui3/bundle_test.go index 9e5ff1908f..a24d303fe9 100644 --- a/internal/tui3/bundle_test.go +++ b/internal/tui3/bundle_test.go @@ -395,128 +395,19 @@ func TestTheNotificationSanitizesItsFields(t *testing.T) { } } -// ── 6. COPY MODE ──────────────────────────────────────────────────────────── +// ── 6. THE CLIPBOARD WIRE ────────────────────────────────────────────────── -// copyApp is a surface with a known transcript, in copy mode. -func copyApp(t *testing.T) *app { - t.Helper() - a := newTestApp(&fakeAgent{model: "m"}) - for _, line := range []string{"alpha", "bravo", "charlie", "delta", "echo"} { - a.entries = append(a.entries, entry{kind: entryNote, text: line}) - } - a.touch() - drive(t, a, ctrlKey('b')) - if !a.copy.on { - t.Fatal("ctrl+b did not enter copy mode") - } - return a -} - -// ctrl+b freezes, ↑ moves, esc leaves — and the status line says which of those -// is happening. -func TestCopyModeFreezesScrollsAndExits(t *testing.T) { - a := copyApp(t) - frozen := append([]string(nil), a.copy.rows...) - - word, _ := a.stateWord() - if word != "COPY" { - t.Fatalf("the status line says %q", word) - } - // The whole frame draws, and it says so where a person is already looking. - screen := plain(frame(a)) - if !strings.Contains(screen, "COPY") { - t.Fatalf("the frame does not say COPY:\n%s", screen) - } - if !strings.Contains(screen, "alpha") { - t.Fatalf("the frozen conversation is not on screen:\n%s", screen) - } - - // The conversation keeps going underneath and the frozen rows do not move. - a.note("this arrived after the freeze") - if len(a.copy.rows) != len(frozen) { - t.Fatalf("the snapshot grew from %d to %d rows", len(frozen), len(a.copy.rows)) - } - body, _ := a.bodyRows(a.width, a.viewHeight()) - for _, r := range body { - if strings.Contains(plain(r.text), "after the freeze") { - t.Fatal("the frozen viewport drew a row that arrived after it froze") - } - } - - at := a.copy.at - drive(t, a, key("up")) - if a.copy.at != at-1 { - t.Fatalf("↑ moved the cursor from %d to %d", at, a.copy.at) - } - drive(t, a, key("down")) - if a.copy.at != at { - t.Fatalf("↓ did not come back: %d", a.copy.at) - } - // The cursor cannot walk off either end. - for i := 0; i < len(a.copy.rows)+5; i++ { - a.copyScroll(-1) - } - if a.copy.at != 0 { - t.Fatalf("the cursor walked past the top: %d", a.copy.at) - } - - drive(t, a, key("esc")) - if a.copy.on { - t.Fatal("esc did not leave copy mode") - } - if !a.stick { - t.Fatal("leaving copy mode did not rejoin the live edge") - } - if word, _ := a.stateWord(); word == "COPY" { - t.Fatal("the status line still says COPY") - } -} - -// v marks, y yanks the span, and what reaches the terminal is an OSC 52 write -// carrying the plain text of the marked rows. -func TestCopyModeYanksTheMarkedSpan(t *testing.T) { - a := copyApp(t) - // Park on a row whose text is known, then mark two rows. - a.copy.at = rowWith(t, a, "charlie") - drive(t, a, key("v")) - if a.copy.mark < 0 { - t.Fatal("v did not drop a mark") - } - drive(t, a, key("up")) - from, to := a.copySpan() - if to-from != 1 { - t.Fatalf("the span is %d rows", to-from+1) - } - if word, _ := a.stateWord(); word != "COPY · 2 lines" { - t.Fatalf("the status line does not count the span: %q", word) - } - - payload := yank(t, a) - if payload != " · bravo\n · charlie" { - t.Fatalf("the yank carried %q", payload) - } - if a.copy.mark >= 0 { - t.Fatal("the mark survived the yank") - } - // v again with no mark set copies the cursor's line alone. - a.copy.at = rowWith(t, a, "delta") - if got := yank(t, a); got != " · delta" { - t.Fatalf("an unmarked yank carried %q", got) - } -} - -// Inside tmux the same write goes out wrapped in the passthrough, with every -// ESC doubled — the bare form is silently eaten there. -func TestTheYankTakesTheTmuxPassthroughInsideTmux(t *testing.T) { +// A copy leaves in band, and inside tmux it goes wrapped in the passthrough +// with every ESC doubled — the bare form is silently eaten there. +func TestACopyTakesTheTmuxPassthroughInsideTmux(t *testing.T) { bare := osc52("hi", false) if want := "\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte("hi")) + "\a"; bare != want { - t.Fatalf("the bare form is %q", bare) + t.Fatalf("the bare write is %q", bare) } wrapped := osc52("hi", true) - if !strings.HasPrefix(wrapped, "\x1bPtmux;\x1b\x1b]52;c;") || !strings.HasSuffix(wrapped, "\x1b\\") { - t.Fatalf("the tmux form is %q", wrapped) + if !strings.HasPrefix(wrapped, "\x1bPtmux;\x1b\x1b]52;c;") || !strings.HasSuffix(wrapped, "\a\x1b\\") { + t.Fatalf("the tmux write is not a passthrough with its ESCs doubled: %q", wrapped) } - for term, want := range map[string]bool{ "tmux-256color": true, "screen-256color": true, "screen": true, "xterm-256color": false, "": false, "alacritty": false, @@ -527,100 +418,6 @@ func TestTheYankTakesTheTmuxPassthroughInsideTmux(t *testing.T) { } } -// yank presses y and returns the text the clipboard write carries. -func yank(t *testing.T, a *app) string { - t.Helper() - cmd, taken := a.copyKey(key("y")) - if !taken || cmd == nil { - t.Fatal("y did not yank") - } - raw, ok := cmd().(tea.RawMsg) - if !ok { - t.Fatalf("the yank is a %T, not a raw write", cmd()) - } - seq, _ := raw.Msg.(string) - body := strings.TrimSuffix(strings.TrimPrefix(seq, "\x1b]52;c;"), "\a") - decoded, err := base64.StdEncoding.DecodeString(body) - if err != nil { - t.Fatalf("the payload is not base64: %q", seq) - } - return string(decoded) -} - -// "a" takes the whole thing under the cursor rather than a range of lines a -// person had to count out, and what comes back is pasteable: the column the -// frame draws down the left of a block is the frame speaking, not the text. -func TestCopyModeTakesTheBlockUnderTheCursorAndYanksItClean(t *testing.T) { - a := newTestApp(&fakeAgent{model: "m"}) - a.pal = newPalette(tokens.TrueColor, false) - // THE CALL COMES BEFORE THE ANSWER, which is the order a turn actually runs - // in and the order THE ANSWER HIERARCHY reads (hierarchy.go): prose with more - // work under it in the same turn is narration and is drawn at the working - // tier, so an answer written above its own tool call would be demoted here — - // and this test is about copying the ANSWER's fence. - a.entries = append(a.entries, - entry{kind: entryUser, text: "how do I print?"}, - entry{kind: entryTool, tool: "read", text: "main.go", status: toolOK, open: true, - detail: toolDetail{Output: "line one\nline two"}}, - entry{kind: entryAssistant, settled: true, text: "Use fmt:\n\n```go\nfmt.Println(\"hi\")\nif ok {\n\tprintln(1)\n}\n```\n\nThat is all."}, - ) - // Copying a result starts with that result on screen, so open both the - // completed turn and its caption before freezing the copy view. - a.openWorkfold(0) - a.setCapOpen(a.conversation(), 1, true) - a.touch() - drive(t, a, ctrlKey('b')) - - // On a code row, "a" takes the fence — and only the fence, without the - // hairline the renderer draws beside it. - a.copy.at = rowWith(t, a, "println(1)") - drive(t, a, key("a")) - if got := yank(t, a); got != "fmt.Println(\"hi\")\nif ok {\n println(1)\n}" { - t.Fatalf("the code block came out as %q", got) - } - - // Pressing it again on the same row widens to the answer the fence lives in, - // which is the block the code row also belongs to. - a.copy.at = rowWith(t, a, "println(1)") - drive(t, a, key("a")) - drive(t, a, key("a")) - got := yank(t, a) - // Flush at both ends: this is the turn's ANSWER, so it carries no work - // gutter for the yank to have to strip (hierarchy.go). - if !strings.HasPrefix(strings.TrimLeft(got, " "), "Use fmt:") || !strings.HasSuffix(got, "That is all.") { - t.Fatalf("the second press did not widen to the answer: %q", got) - } - if strings.Contains(got, tokens.GlyphCodeGutter) { - t.Fatalf("the answer carried the code hairline: %q", got) - } - - // A tool's output is a block too, and its stem is chrome the same way. - a.copy.at = rowWith(t, a, "line two") - drive(t, a, key("a")) - if got := yank(t, a); !strings.Contains(got, "line one\nline two") { - t.Fatalf("the tool result came out as %q", got) - } else if strings.Contains(got, "│") { - t.Fatalf("the tool result carried its stem: %q", got) - } - - // A blank belongs to nothing, so "a" there guesses at nothing. - blank := -1 - for i, line := range a.copy.text { - if strings.TrimSpace(line) == "" && a.copy.owner[i] < 0 { - blank = i - break - } - } - if blank < 0 { - t.Fatal("the layout emitted no blank between the blocks") - } - a.copy.at, a.copy.mark = blank, -1 - drive(t, a, key("a")) - if a.copy.mark >= 0 { - t.Fatal("a blank row was taken as a block") - } -} - // A waiting sign-in is the one thing here a person needs somewhere else, so a // press on the card copies the link whole — and the card says it did. func TestPressingAWaitingSignInCopiesItsLink(t *testing.T) { @@ -735,18 +532,6 @@ func TestTheSelectCommandAnswersWhenThereIsNothingToHandOver(t *testing.T) { } } -// rowWith is the frozen row holding a word. -func rowWith(t *testing.T, a *app, word string) int { - t.Helper() - for i, line := range a.copy.text { - if strings.Contains(line, word) { - return i - } - } - t.Fatalf("no frozen row holds %q:\n%s", word, strings.Join(a.copy.text, "\n")) - return -1 -} - // ── 7. THE LIGHT LADDER ───────────────────────────────────────────────────── // The authored values, and the one law that has to hold on the rung where hues diff --git a/internal/tui3/clipboard.go b/internal/tui3/clipboard.go new file mode 100644 index 0000000000..e97e55fd27 --- /dev/null +++ b/internal/tui3/clipboard.go @@ -0,0 +1,195 @@ +package tui3 + +import ( + "encoding/base64" + "strings" + + "github.com/Agent-Field/codeaf/internal/tui2/tokens" +) + +// GETTING TEXT OUT: the mouse, and the wire a copy goes down. +// +// This surface runs in the alt screen (view.go), which is what lets the +// conversation scroll under its own anchor — and which takes the terminal's +// own scrollback and selection away in the same breath. Two doors give a +// person their text back, and both are the mouse's: a drag across the rows +// copies what it covers the moment the button is released (dragselect.go), and +// ctrl+s hands the pointer to the terminal so its own drag works +// ([app.releaseMouse]). Every copy this surface makes — the sweep, a path +// under a row, a sign-in link, a job's log path — leaves through [osc52]. +// +// THERE WAS A KEYBOARD DOOR, AND IT IS GONE. Copy mode — ctrl+b and /copy, a +// frozen viewport read with ↑↓, marked with v, taken by block with a and +// yanked with y — stood here from the alt screen's first day until 2026-09-22, +// when the owner judged it no use beside the drag and had it removed whole: +// the mode, the command, the status word, the keys row, the tip that taught +// it, and every gate the rest of the surface kept on it. ctrl+b is bound to +// nothing in a conversation now, and home's box keeps it as the caret's left. +// +// ── ctrl+s ────────────────────────────────────────────────────────────────── +// +// The surface takes the pointer by default (view.go): while it holds it, +// dragging across an answer scrolls or hovers, and the drag every person alive +// already knows selects nothing. dragselect.go answers that with a sweep of +// its own; this is the other answer, for a person who wants THEIR terminal's +// selection — its word-doubling, its rectangle, its paste buffer. +// +// ctrl+s gives the pointer to the terminal. Drag, copy the way that terminal +// copies, and the next key pressed here takes it back — there is no mode to +// leave and nothing to remember, because the gesture that ends it is the +// gesture that follows it anyway. While it is out, one dim line says so. +// +// It is deliberately NOT the ui.mouse setting under another name. The setting +// is a standing decision about how this surface behaves; this is a person +// reaching for one paragraph, which is a thing they do between two keystrokes +// and should not have to open a panel for. +// +// ── WHY OSC 52 AND NOT A CLIPBOARD LIBRARY ────────────────────────────────── +// +// Because the terminal may not be on this machine. OSC 52 is a clipboard write +// carried in-band, over the same pipe the drawing goes down, so it works +// through ssh and through a container without a display, and it is the only +// mechanism that does. Inside tmux it needs the passthrough wrapper — tmux +// eats sequences it does not recognize unless they are addressed to it — +// hence [tmuxTerm] and the doubled ESC below. +// +// Bubble Tea has [tea.SetClipboard], which sends the bare form. This file +// builds its own because the bare form is the one that silently does nothing +// inside a multiplexer, which is where a lot of these sessions live. + +// selectKey hands the pointer over. ctrl+s survives the trip: the terminal is +// in raw mode while this surface is up, and raw mode is exactly what turns off +// the flow control that would otherwise have eaten it. +const selectKey = "ctrl+s" + +// releaseMouse toggles the handover, and reports whether the surface had a +// pointer to hand over at all. With ui.mouse off the terminal already has it, +// so there is nothing to do and nothing to say — the drag being asked for +// works already. +func (a *app) releaseMouse() bool { + if !a.mouse { + return false + } + a.released = !a.released + // THE HOVER GOES WITH IT. Nothing reports where the pointer is any more, so + // whatever row was lit stays lit — a band under a pointer that has since + // moved somewhere else entirely, sitting on the screen for the whole of the + // drag somebody is trying to make (hover.go). + if a.released { + a.dropHover() + } + a.touch() + return true +} + +// takeMouseBack ends the handover on the person's next keystroke. It reports +// whether it did anything so the caller can stay quiet when it did not. +func (a *app) takeMouseBack() bool { + if !a.released { + return false + } + a.released = false + a.touch() + return true +} + +// ── WHAT A COPY CARRIES ───────────────────────────────────────────────────── +// +// What a person copies must be what a person could PASTE. The sweep keeps the +// rows plain as well as painted, and it lifts the column the renderer draws +// down the left of a block — the stem under an expanded tool call, the +// hairline beside a fence. Those cells are the frame saying "these rows are +// one thing"; in a paste buffer they are a box-drawing character welded to the +// front of every line of somebody's stack trace. + +// copyRails are the columns this surface draws down the LEFT of a block and +// repeats on every one of its rows: the stem an expanded tool's output hangs +// from (styles.go), under both its glyph sets, and the hairline beside a fenced +// code block or a blockquote (markdown.go). +// +// The one-off marks are NOT here and must not be. "› " on a message and "· " on +// a note sit on the first row of a block and say who is speaking, which is a +// fact somebody quoting a conversation usually wants kept. A rail says nothing +// except "these rows are one thing", which the paste already shows. +// The wrapped-code row's lead is here for [copyCodeRow]'s reason: a line the +// renderer split is still one line of source, and a paste that carried `↳ ` into +// the middle of it would be a paste that does not compile. +var copyRails = []string{railCont, railContASCII, + tokens.GlyphCodeGutter + " ", mdContMark + tokens.GlyphCodeGutter + " "} + +// copyClean is one drawn row as it should reach a clipboard: the drawn left +// rail lifted, and the trailing cells — hover padding, row padding — with it. +func copyClean(line string, gut int) string { + // THE READING GUTTER IS FRAME FURNITURE AND NEVER TEXT (gutter.go), so it + // comes off before anything else is decided. It is dropped by width rather + // than by trimming, because what is left of the indent below IS text about + // the block — a tool's output sits two columns in, and a copy that lost that + // would paste a diff with its hierarchy flattened. + line = strings.TrimPrefix(line, strings.Repeat(" ", gut)) + trimmed := strings.TrimLeft(line, " ") + indent := line[:len(line)-len(trimmed)] + for _, rail := range copyRails { + if rest, ok := strings.CutPrefix(trimmed, rail); ok { + // The indent BEFORE the rail goes too. It is the block's own inset on + // the frame, not anything the text said about itself, and code inside a + // fence keeps its own indentation because that sits after the rail. + return strings.TrimRight(rest, " ") + } + } + return strings.TrimRight(indent+trimmed, " ") +} + +// copyCodeRow reports whether a drawn row belongs to a fenced block: it sits +// behind the hairline markdown puts down the left of one. +// +// IT ALSO KNOWS THE CONTINUATION MARKER, and it has to. A code line too long +// for the frame is wrapped rather than cut (markdown.go's [segmentedMarkdown]), +// and the row carrying the rest of it opens on [mdContMark] where its +// neighbours open on spaces — so a run of code rows read by the gutter alone +// ENDED at the first wrapped line (markdownwrap_test.go holds the case). +func copyCodeRow(line string) bool { + trimmed := strings.TrimLeft(line, " ") + trimmed = strings.TrimPrefix(trimmed, mdContMark) + return strings.HasPrefix(trimmed, tokens.GlyphCodeGutter) +} + +// ── OSC 52 ────────────────────────────────────────────────────────────────── + +// osc52 is a clipboard write, in the form the terminal in front of us speaks. +// +// ESC ] 52 ; c ; BEL the sequence itself +// ESC P tmux ; ESC \ the same, addressed to tmux +// +// The "c" is the CLIPBOARD selection rather than "p" (primary): a copy made on +// purpose, and primary is what a terminal's own drag fills. +func osc52(payload string, tmux bool) string { + seq := "\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte(payload)) + "\a" + if !tmux { + return seq + } + // tmux forwards a DCS passthrough to the terminal underneath it verbatim, + // with one rule: every ESC inside must be doubled, or tmux reads the first + // one as the end of the passthrough. + return "\x1bPtmux;" + strings.ReplaceAll(seq, "\x1b", "\x1b\x1b") + "\x1b\\" +} + +// tmuxTerm reports whether this surface is inside a multiplexer, from TERM +// alone. TERM is what tmux and screen both set for the session they host +// ("screen-256color", "tmux-256color"), and it is the one answer that is true +// whether the multiplexer was started before this process or around it — +// $TMUX, the other candidate, is unset in a pane that inherited its environment +// from somewhere else. +func tmuxTerm(env func(string) string) bool { + if env == nil { + return false + } + term := strings.ToLower(strings.TrimSpace(env("TERM"))) + return strings.HasPrefix(term, "screen") || strings.HasPrefix(term, "tmux") +} + +func clampInt(v, low, high int) int { + if high < low { + return low + } + return min(max(v, low), high) +} diff --git a/internal/tui3/commands.go b/internal/tui3/commands.go index 0a9ee6c771..015acf7d92 100644 --- a/internal/tui3/commands.go +++ b/internal/tui3/commands.go @@ -347,18 +347,17 @@ var commands = []command{ // be the most expensive pun on the surface. {name: "cache", desc: "the shared build cache — how big, and where"}, {name: "cache", args: "clean", desc: "…delete it to free disk · asks before anything is removed"}, - // THE THREE DOORS ONTO GETTING TEXT OUT, and they sit beside /help because - // that is where a person goes with the question they answer. The keys behind - // the first two are the least discoverable on the surface — nothing on the - // screen says either exists — and "why can I not copy this" is the first - // question this surface gets asked. /copy is the keyboard's way, /select the - // mouse's (copymode.go). + // THE TWO DOORS ONTO GETTING TEXT OUT, and they sit beside /help because + // that is where a person goes with the question they answer. The key behind + // the first is the least discoverable on the surface — nothing on the screen + // says it exists — and "why can I not copy this" is the first question this + // surface gets asked. /select is the mouse's way (clipboard.go); /copy, the + // keyboard's, was copy mode and went with it on 2026-09-22. // - // /export is the third and it is a different KIND of answer: those two hand + // /export is the second and it is a different KIND of answer: that one hands // over what is on the screen, and this one writes the whole conversation to a - // file somebody can send (export.go). It is last of the three because it is + // file somebody can send (export.go). It is last of the two because it is // the one a person reaches for once, at the end. - {name: "copy", desc: "read the conversation back and copy from it · ctrl+b"}, {name: "select", desc: "drag to select with your mouse · ctrl+s"}, // TWO ROWS FOR ONE COMMAND, the way /model has two. A single row carrying // would make the bare form — which is the one nearly everybody wants — @@ -1029,7 +1028,6 @@ func helpText(file string, chords chordSpelling) string { // rows at the foot of this list already use. helpKeyRow(spellOutKey, "over a draft: spell it out · what it means · enter adds it to yours"), "ctrl+o expand this turn's tool calls · click one to open it · in a task, scroll up does too", - "ctrl+b copy mode · ↑↓ move · v marks · a takes the block · y yanks", "ctrl+s drag to select with your mouse · any key ends it", "enter mid-answer: stops the current reply and steers these words in", // AND THE THIRD THING TO DO WITH A SENTENCE TYPED OVER A RUNNING ANSWER diff --git a/internal/tui3/copymode.go b/internal/tui3/copymode.go deleted file mode 100644 index abceb85b2d..0000000000 --- a/internal/tui3/copymode.go +++ /dev/null @@ -1,506 +0,0 @@ -package tui3 - -import ( - "encoding/base64" - "strings" - - tea "charm.land/bubbletea/v2" - "github.com/charmbracelet/x/ansi" - - "github.com/Agent-Field/codeaf/internal/tui2/tokens" -) - -// COPY MODE: ctrl+b, and the reason it exists is the alt screen. -// -// This surface runs in the alt screen (view.go), which is what lets the -// conversation scroll under its own anchor — and which takes the terminal's own -// scrollback and selection away in the same breath. A person who wants the -// stack trace that just went past has, without this, exactly two options: drag -// the mouse across it while the surface is also tracking the mouse, or scroll -// up and read it out loud to themselves. -// -// So: ctrl+b freezes the viewport and hands the keyboard to a reader. -// -// ↑ ↓ pgup pgdn move the cursor through the frozen rows -// v drop a mark, or lift it -// a take the whole block under the cursor -// y yank — the cursor's line, or the marked span -// esc leave, and rejoin the live edge -// COPY in the status line, for as long as it is up -// -// ── WHY "a" ── -// -// Because the thing a person wants is almost never a range of lines: it is an -// answer, a tool's output, a fenced block of code. Building that out of v and -// nine presses of ↓ is the reader doing arithmetic to say something it already -// knows — every row of the snapshot remembers which block it came from, and a -// fence announces itself by the hairline down its left. So "a" asks for the -// block and the cursor stays where it was, which means a on a code row inside -// an answer takes the code, and a again takes the answer around it. -// -// ── WHAT COMES OUT ── -// -// What a person copies must be what a person could PASTE. That is why the -// snapshot is kept plain as well as painted, and it is why the yank also lifts -// the column the renderer draws down the left of a block — the stem under an -// expanded tool call, the hairline beside a fence. Those cells are the frame -// saying "these rows are one thing"; in a paste buffer they are a box-drawing -// character welded to the front of every line of somebody's stack trace. -// -// ── WHAT "FREEZES" MEANS ── -// -// The rows are SNAPSHOTTED on entry, painted and plain, and the frozen list is -// what the frame draws until esc. The conversation underneath keeps going — a -// turn that was running keeps streaming, tool rows keep landing, the follow-up -// queue keeps draining — and none of it moves the rows being read. That is the -// whole point: a viewport that reflowed under somebody trying to copy line 14 -// would hand them line 19. -// -// The snapshot is also why a click does nothing while it is up (app.go): row 14 -// of a frozen list is not row 14 of the conversation, and a click that expanded -// "whatever is there now" would open a call the person cannot see. -// -// ── WHY OSC 52 AND NOT A CLIPBOARD LIBRARY ── -// -// Because the terminal may not be on this machine. OSC 52 is a clipboard write -// carried in-band, over the same pipe the drawing goes down, so it works -// through ssh and through a container without a display, and it is the only -// mechanism that does. Inside tmux it needs the passthrough wrapper — tmux -// eats sequences it does not recognize unless they are addressed to it — hence -// [tmuxTerm] and the doubled ESC below. -// -// Bubble Tea has [tea.SetClipboard], which sends the bare form. This file -// builds its own because the bare form is the one that silently does nothing -// inside a multiplexer, which is where a lot of these sessions live. - -// ── AND THE OTHER DOOR: ctrl+s ────────────────────────────────────────────── -// -// Copy mode is the keyboard's answer. This is the mouse's, and it exists -// because the surface takes the pointer by default (view.go): while it holds -// it, dragging across an answer scrolls or hovers, and the drag every person -// alive already knows selects nothing. -// -// ctrl+s gives the pointer to the terminal. Drag, copy the way that terminal -// copies, and the next key pressed here takes it back — there is no mode to -// leave and nothing to remember, because the gesture that ends it is the -// gesture that follows it anyway. While it is out, one dim line says so. -// -// It is deliberately NOT the ui.mouse setting under another name. The setting -// is a standing decision about how this surface behaves; this is a person -// reaching for one paragraph, which is a thing they do between two keystrokes -// and should not have to open a panel for. - -// selectKey hands the pointer over. ctrl+s survives the trip: the terminal is -// in raw mode while this surface is up, and raw mode is exactly what turns off -// the flow control that would otherwise have eaten it. -const selectKey = "ctrl+s" - -// releaseMouse toggles the handover, and reports whether the surface had a -// pointer to hand over at all. With ui.mouse off the terminal already has it, -// so there is nothing to do and nothing to say — the drag being asked for -// works already. -func (a *app) releaseMouse() bool { - if !a.mouse { - return false - } - a.released = !a.released - // THE HOVER GOES WITH IT. Nothing reports where the pointer is any more, so - // whatever row was lit stays lit — a band under a pointer that has since - // moved somewhere else entirely, sitting on the screen for the whole of the - // drag somebody is trying to make (hover.go). - if a.released { - a.dropHover() - } - a.touch() - return true -} - -// takeMouseBack ends the handover on the person's next keystroke. It reports -// whether it did anything so the caller can stay quiet when it did not. -func (a *app) takeMouseBack() bool { - if !a.released { - return false - } - a.released = false - a.touch() - return true -} - -// copyKeysWord is the keys row while the viewport is frozen, under either box: -// the reader's keys are the only keys that work, so they are the only keys the -// row may name. -const copyKeysWord = "v select · a block · y yank · esc" - -// copyMode is the frozen viewport's whole state. The zero value is off, except -// for mark, which [newApp] sets to -1 — nothing is marked. -type copyMode struct { - on bool - // The reading gutter belongs to the snapshot, even after a resize. - gutter int - // rows is the snapshot as it is drawn, and text the same rows stripped of - // every escape sequence. Two slices rather than one strip-per-yank because - // what a person copies must be what a person could paste: SGR in a paste - // buffer is line noise in whatever they paste it into. - rows []string - text []string - // owner is which block each row came from — the index into the list that was - // frozen, or -1 for a blank the spacing law emitted between two of them - // (render.go's [app.layout]). It is recorded at the freeze rather than - // recomputed, for the reason the rows themselves are: the list underneath - // keeps moving, and a block resolved afterwards would be a different block. - owner []int - // at is the cursor's row, top the first row on screen, and mark the other - // end of the selection or -1. - at, top, mark int -} - -// enterCopy freezes the viewport. It snapshots the CURRENT row list and parks -// the cursor on the last row a person can see, which is where their eye is — -// the live edge is what they were watching when they reached for the key. -func (a *app) enterCopy() { - if a.copy.on { - return - } - width := a.bodyWidth() - height := a.viewHeight() - rows := a.visible(width) - if len(rows) == 0 { - return - } - snapshot := make([]string, 0, len(rows)) - plain := make([]string, 0, len(rows)) - owner := make([]int, 0, len(rows)) - for _, r := range rows { - snapshot = append(snapshot, r.text) - plain = append(plain, ansi.Strip(r.text)) - owner = append(owner, r.entry) - } - top := a.offsetFor(len(rows), height) - at := min(top+height-1, len(rows)-1) - a.copy = copyMode{on: true, gutter: textGutterCols(width), rows: snapshot, text: plain, owner: owner, at: at, top: top, mark: -1} - a.noticeEvent(eventCopyEntered) - a.touch() -} - -// exitCopy thaws it and rejoins the live edge, because a reader who has -// finished reading wants the conversation back. -// -// WHICHEVER EDGE WAS FROZEN. A room's rows are what [app.freezeRoom] snapshots, -// so thawing back onto the transcript's edge would drop the reader out of the -// page they were reading and lose the conversation's scroll on the way (room.go -// carried this as a known seam; the room's own stick is what closes it). -func (a *app) exitCopy() { - a.copy = copyMode{mark: -1} - if a.room != nil { - a.room.stick = true - a.roomTouched() - return - } - a.stick = true - a.follow() - a.touch() -} - -// copyKey routes the frozen viewport's keys and says whether it took one. -// -// It takes EVERYTHING except the keys read above it (ctrl+c is the door and is -// never modal), because copy mode is a reading mode: a keystroke that fell -// through to the draft would type into a box the person cannot see the effect -// of. -func (a *app) copyKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { - if !a.copy.on { - return nil, false - } - switch msg.String() { - case "esc", "ctrl+b", "q": - a.exitCopy() - case "up", "k": - a.copyScroll(-1) - case "down", "j": - a.copyScroll(1) - case "pgup": - a.copyScroll(-a.scrollPage()) - case "pgdown": - a.copyScroll(a.scrollPage()) - case "home": - a.copyScroll(-len(a.copy.rows)) - case "end": - a.copyScroll(len(a.copy.rows)) - case "v": - a.copyMark() - case "a": - a.copyBlock() - case "y": - return a.copyYank(), true - } - return nil, true -} - -// copyScroll moves the cursor and keeps it on screen. The window follows the -// CURSOR rather than the other way round: there is no second position to keep -// in step, so there is nothing for the two to disagree about. -func (a *app) copyScroll(delta int) { - c := &a.copy - c.at = clampInt(c.at+delta, 0, len(c.rows)-1) - height := a.viewHeight() - if height < 1 { - height = 1 - } - switch { - case c.at < c.top: - c.top = c.at - case c.at >= c.top+height: - c.top = c.at - height + 1 - } - c.top = clampInt(c.top, 0, max(len(c.rows)-height, 0)) - a.touch() -} - -// copyMark drops the far end of a selection, or lifts it. The cursor is always -// the NEAR end: v then ↓↓↓ grows the span downward, exactly as it does in every -// other reader that has this key. -func (a *app) copyMark() { - if a.copy.mark >= 0 { - a.copy.mark = -1 - } else { - a.copy.mark = a.copy.at - } - a.touch() -} - -// copyBlock selects the whole thing the cursor is standing in, and leaves the -// cursor where it was so the next press can widen from the same spot. -// -// It asks the narrower question first. A fenced code block is a run of rows -// carrying the code hairline, and inside an answer it is almost always what -// somebody reached for — so a on a code row takes the code, and a again, now -// that the run is already selected, takes the answer it lives in. Anywhere -// else there is only the block, and one press has it. -// -// A blank row belongs to nothing (the spacing law emits it between two things, -// render.go's [app.layout]), so a there does nothing rather than guessing at -// which neighbour was meant. -func (a *app) copyBlock() { - c := &a.copy - if c.at < 0 || c.at >= len(c.text) { - return - } - from, to, ok := c.fenceAt(c.at) - if !ok || (c.mark == from && c.at == to) || (c.mark == to && c.at == from) { - from, to, ok = c.entryAt(c.at) - } - if !ok { - return - } - // The mark is the FAR end and the cursor the near one, which is the rule the - // whole mode runs on ([app.copyMark]): dropping them the other way round - // would make the next ↓ shrink a selection the person just widened. - if c.at <= from { - c.at, c.mark = from, to - } else { - c.at, c.mark = to, from - } - a.touch() -} - -// fenceAt is the run of code rows around one row: rows drawn behind the -// hairline markdown puts down the left of a fenced block (markdown.go). -func (c *copyMode) fenceAt(at int) (int, int, bool) { - if !copyCodeRow(c.text[at]) { - return 0, 0, false - } - from, to := at, at - for from > 0 && copyCodeRow(c.text[from-1]) { - from-- - } - for to < len(c.text)-1 && copyCodeRow(c.text[to+1]) { - to++ - } - return from, to, true -} - -// copyCodeRow reports whether a drawn row belongs to a fenced block: it sits -// behind the hairline markdown puts down the left of one. -// -// IT ALSO KNOWS THE CONTINUATION MARKER, and it has to. A code line too long -// for the frame is wrapped rather than cut (markdown.go's [segmentedMarkdown]), -// and the row carrying the rest of it opens on [mdContMark] where its -// neighbours open on spaces — so a run of code rows read by the gutter alone -// ENDED at the first wrapped line, and `a` selected the top half of a block. -func copyCodeRow(line string) bool { - trimmed := strings.TrimLeft(line, " ") - trimmed = strings.TrimPrefix(trimmed, mdContMark) - return strings.HasPrefix(trimmed, tokens.GlyphCodeGutter) -} - -// entryAt is the run of rows one block of the frozen list occupies. -func (c *copyMode) entryAt(at int) (int, int, bool) { - if at >= len(c.owner) || c.owner[at] < 0 { - return 0, 0, false - } - block := c.owner[at] - from, to := at, at - for from > 0 && c.owner[from-1] == block { - from-- - } - for to < len(c.owner)-1 && c.owner[to+1] == block { - to++ - } - return from, to, true -} - -// copySpan is the selected range, inclusive, low first. -func (a *app) copySpan() (int, int) { - if a.copy.mark < 0 { - return a.copy.at, a.copy.at - } - if a.copy.mark <= a.copy.at { - return a.copy.mark, a.copy.at - } - return a.copy.at, a.copy.mark -} - -// copyYank writes the selection to the system clipboard and lifts the mark. -// -// It stays IN copy mode: a person copying a stack trace out of a log usually -// wants the next thing under it too, and esc is right there. The mark is lifted -// because leaving it would make the next y copy the same span again by -// accident. -func (a *app) copyYank() tea.Cmd { - from, to := a.copySpan() - if from < 0 || to >= len(a.copy.text) { - return nil - } - lines := make([]string, 0, to-from+1) - for _, line := range a.copy.text[from : to+1] { - lines = append(lines, copyClean(line, a.copy.gutter)) - } - a.copy.mark = -1 - a.touch() - return tea.Raw(osc52(strings.Join(lines, "\n"), a.tmux)) -} - -// copyRails are the columns this surface draws down the LEFT of a block and -// repeats on every one of its rows: the stem an expanded tool's output hangs -// from (styles.go), under both its glyph sets, and the hairline beside a fenced -// code block or a blockquote (markdown.go). -// -// The one-off marks are NOT here and must not be. "› " on a message and "· " on -// a note sit on the first row of a block and say who is speaking, which is a -// fact somebody quoting a conversation usually wants kept. A rail says nothing -// except "these rows are one thing", which the paste already shows. -// The wrapped-code row's lead is here for [copyCodeRow]'s reason: a line the -// renderer split is still one line of source, and a paste that carried `↳ ` into -// the middle of it would be a paste that does not compile. -var copyRails = []string{railCont, railContASCII, - tokens.GlyphCodeGutter + " ", mdContMark + tokens.GlyphCodeGutter + " "} - -// copyClean is one frozen row as it should reach a clipboard: the drawn left -// rail lifted, and the trailing cells — hover padding, row padding — with it. -func copyClean(line string, gut int) string { - // THE READING GUTTER IS FRAME FURNITURE AND NEVER TEXT (gutter.go), so it - // comes off before anything else is decided. It is dropped by width rather - // than by trimming, because what is left of the indent below IS text about - // the block — a tool's output sits two columns in, and a yank that lost that - // would paste a diff with its hierarchy flattened. - line = strings.TrimPrefix(line, strings.Repeat(" ", gut)) - trimmed := strings.TrimLeft(line, " ") - indent := line[:len(line)-len(trimmed)] - for _, rail := range copyRails { - if rest, ok := strings.CutPrefix(trimmed, rail); ok { - // The indent BEFORE the rail goes too. It is the block's own inset on - // the frame, not anything the text said about itself, and code inside a - // fence keeps its own indentation because that sits after the rail. - return strings.TrimRight(rest, " ") - } - } - return strings.TrimRight(indent+trimmed, " ") -} - -// copyRows is what the frame draws while the viewport is frozen: the visible -// slice of the snapshot, with the selection highlighted. -// -// The selection wears THE GROUND LADDER's MARK step (styles.go), which is the -// loudest of the three and exists for exactly this: a span, held open, running -// across many rows at once. It used to wear the pointer's own step, and that -// was one statement doing two jobs — "the pointer is here" and "these forty -// rows are what a yank would take" are not the same claim and may not be the -// same tint. The cursor is the moving end of the span, which is visible in the -// moving, and a terminal below ANSI256 gets no highlight at all and reads the -// span off the status line's count instead. -func (a *app) copyRows(width, height int) ([]row, int) { - if height <= 0 || len(a.copy.rows) == 0 { - return nil, 0 - } - from, to := a.copySpan() - // The window is clamped HERE as well as in [app.copyScroll], because the - // frame can shrink between the two: a resize while the viewport is frozen - // leaves a top that was legal for the old height, and a slice taken from it - // would draw an empty screen rather than the rows somebody is reading. - top := clampInt(a.copy.top, 0, max(len(a.copy.rows)-height, 0)) - end := min(top+height, len(a.copy.rows)) - out := make([]row, 0, end-top) - for i := top; i < end; i++ { - text := a.copy.rows[i] - if i >= from && i <= to { - text = a.pal.mark(text, width) - } - out = append(out, row{text: text, entry: -1}) - } - if pad := height - len(out); pad > 0 { - return out, pad - } - return out, 0 -} - -// copyWord is what the status line says while this is up. It carries the count -// as well as the mode, because a marked span longer than the screen is a span a -// person cannot otherwise measure. -func (a *app) copyWord() string { - from, to := a.copySpan() - if n := to - from + 1; n > 1 { - return "COPY · " + itoa(n) + " lines" - } - return "COPY" -} - -// ── OSC 52 ────────────────────────────────────────────────────────────────── - -// osc52 is a clipboard write, in the form the terminal in front of us speaks. -// -// ESC ] 52 ; c ; BEL the sequence itself -// ESC P tmux ; ESC \ the same, addressed to tmux -// -// The "c" is the CLIPBOARD selection rather than "p" (primary): a yank is a -// deliberate copy, and primary is what a mouse drag fills. -func osc52(payload string, tmux bool) string { - seq := "\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte(payload)) + "\a" - if !tmux { - return seq - } - // tmux forwards a DCS passthrough to the terminal underneath it verbatim, - // with one rule: every ESC inside must be doubled, or tmux reads the first - // one as the end of the passthrough. - return "\x1bPtmux;" + strings.ReplaceAll(seq, "\x1b", "\x1b\x1b") + "\x1b\\" -} - -// tmuxTerm reports whether this surface is inside a multiplexer, from TERM -// alone. TERM is what tmux and screen both set for the session they host -// ("screen-256color", "tmux-256color"), and it is the one answer that is true -// whether the multiplexer was started before this process or around it — -// $TMUX, the other candidate, is unset in a pane that inherited its environment -// from somewhere else. -func tmuxTerm(env func(string) string) bool { - if env == nil { - return false - } - term := strings.ToLower(strings.TrimSpace(env("TERM"))) - return strings.HasPrefix(term, "screen") || strings.HasPrefix(term, "tmux") -} - -func clampInt(v, low, high int) int { - if high < low { - return low - } - return min(max(v, low), high) -} diff --git a/internal/tui3/detach.go b/internal/tui3/detach.go index 69b7a66c55..3aadb36b42 100644 --- a/internal/tui3/detach.go +++ b/internal/tui3/detach.go @@ -374,10 +374,9 @@ func (a *app) clearConversation() { // else. a.workOpen = map[int]bool{} a.dropHover() - // A frozen viewport and a cut line are modes a person is in the middle of, - // and there is no honest way to be in the middle of one in a conversation - // nobody is looking at (copymode.go, rewind.go). - a.copy = copyMode{mark: -1} + // A cut line is a mode a person is in the middle of, and there is no honest + // way to be in the middle of one in a conversation nobody is looking at + // (rewind.go). a.rew = rewindMode{} a.rewSay, a.rewSayAt = "", time.Time{} // The rail goes with its nodes, its rooms and its pilots (task.go). diff --git a/internal/tui3/dragspan_test.go b/internal/tui3/dragspan_test.go index 0cc95b38cc..6250377ad5 100644 --- a/internal/tui3/dragspan_test.go +++ b/internal/tui3/dragspan_test.go @@ -63,7 +63,7 @@ func TestASweepAcrossRowsTakesTailWholeAndHead(t *testing.T) { t.Fatalf("the ends are wrong:\n%q", got) } // The middle row keeps the indent it is drawn with, as copy mode keeps it: - // an inset is the block's own and pastes as such (copymode.go's copyClean). + // an inset is the block's own and pastes as such (clipboard.go's copyClean). if !strings.Contains(got, "the person wants fmt\n") { t.Fatalf("the middle row is not taken whole:\n%q", got) } diff --git a/internal/tui3/foot.go b/internal/tui3/foot.go index 6711c80310..d538f098b7 100644 --- a/internal/tui3/foot.go +++ b/internal/tui3/foot.go @@ -400,7 +400,7 @@ func (a *app) doorPress(door statusDoor) (tea.Cmd, bool) { // answer, and laying it out is what writes the doors — read the other way // round, this would be testing a column from the frame before this one. func (a *app) statusDoorPress(x, y int) (tea.Cmd, bool) { - if a.copy.on || a.at(pageSettings) || a.pick.open { + if a.at(pageSettings) || a.pick.open { return nil, false } mark, ok := a.chromeAt(y) @@ -697,7 +697,7 @@ func seamSpans(head, model, rider, rung, gate string) (hudSpan, hudSpan, hudSpan // conversation's model; a room's own door is on its status row // ([app.statusPress]). func (a *app) legendModelPress(x, y int) bool { - if a.copy.on || a.at(pageSettings) || a.pick.open { + if a.at(pageSettings) || a.pick.open { return false } mark, ok := a.chromeAt(y) @@ -727,7 +727,7 @@ func (a *app) legendModelPress(x, y int) bool { // resolved word read back, so the work goes to the loop rather than being run // under the pointer. func (a *app) legendEffortPress(x, y int) (tea.Cmd, bool) { - if a.copy.on || a.at(pageSettings) || a.pick.open { + if a.at(pageSettings) || a.pick.open { return nil, false } mark, ok := a.chromeAt(y) @@ -747,7 +747,7 @@ func (a *app) legendEffortPress(x, y int) (tea.Cmd, bool) { // WALKS THE GATE'S WHEEL ONE STOP on the rung's own terms (approvalchip.go): // one press, one step, with a note describing the resulting posture. func (a *app) legendApprovalPress(x, y int) (tea.Cmd, bool) { - if a.copy.on || a.at(pageSettings) || a.pick.open || a.roomOpen() { + if a.at(pageSettings) || a.pick.open || a.roomOpen() { return nil, false } mark, ok := a.chromeAt(y) diff --git a/internal/tui3/gutter_test.go b/internal/tui3/gutter_test.go index b55c18fe33..b050c9f290 100644 --- a/internal/tui3/gutter_test.go +++ b/internal/tui3/gutter_test.go @@ -193,7 +193,7 @@ func TestATaskPageIsReadTwoColumnsInToo(t *testing.T) { // A YANK PASTES WHAT WAS SAID AND NOT THE FRAME IT WAS SAID IN. The gutter is // furniture; the indent under it is the block's own hierarchy and stays -// (copymode.go's [copyClean]). +// (clipboard.go's [copyClean]). func TestAYankLiftsTheGutterAndKeepsTheIndent(t *testing.T) { if got := copyClean(" the parser guard is back", spacingConversationLead); got != "the parser guard is back" { t.Fatalf("a yank of a guttered row pasted %q", got) diff --git a/internal/tui3/home.go b/internal/tui3/home.go index e34bfee462..83d455acf5 100644 --- a/internal/tui3/home.go +++ b/internal/tui3/home.go @@ -3813,7 +3813,7 @@ func (a *app) homeDoorOpen() bool { // it: Home is reachable and no copy or rewind mode owns the foot. The draft // may contain words because back navigation preserves them. func (a *app) homeDoorShowing() bool { - return (a.roomOpen() || a.homeDoorOpen()) && !a.copy.on && !a.rew.on + return (a.roomOpen() || a.homeDoorOpen()) && !a.rew.on } // homeDoorPress is a click on that advertisement. diff --git a/internal/tui3/homeslash.go b/internal/tui3/homeslash.go index e3544c0ed8..57e9d9e576 100644 --- a/internal/tui3/homeslash.go +++ b/internal/tui3/homeslash.go @@ -201,7 +201,7 @@ func homeFate(word, rest string) string { case "land", "workspace": return fateBehind case "files", "permissions", "connect", "harness", "subharness", "autonomy", - "copy", "select", "rewind", "compact", "export", "drafts", "manual": + "select", "rewind", "compact", "export", "drafts", "manual": // /manual IS HERE SINCE 2026-09-22 and not among the answers: it is a // turn of a conversation now (manualcmd.go), and a turn needs one. As // an answer it printed the pages into the conversation BEHIND home, diff --git a/internal/tui3/hop.go b/internal/tui3/hop.go index 60f7decc14..f61fe66f0b 100644 --- a/internal/tui3/hop.go +++ b/internal/tui3/hop.go @@ -341,7 +341,7 @@ func (a *app) hopAvailable() bool { // that opened over either would be drawn over a gesture somebody is in the // middle of. They are asked HERE because this claim is read above the place // router and so does not pass through either of their own arbitration. -func (a *app) hopMayOpen() bool { return !a.composer.open && !a.copy.on } +func (a *app) hopMayOpen() bool { return !a.composer.open } // THERE USED TO BE A SECOND DOOR HERE, `hopOpenAll`: the card raised with its // fold already open, which is what the `Chats ▾` control at the right end of the diff --git a/internal/tui3/hover.go b/internal/tui3/hover.go index 9ffb5a2236..24957ebe46 100644 --- a/internal/tui3/hover.go +++ b/internal/tui3/hover.go @@ -628,7 +628,7 @@ func (a *app) hoverTarget(x, y int) hoverAt { // inside a room (roomseam.go). The home door at the other end of the // same line lights through its own reading (home.go's // [app.hoverHomeDoor]). - if a.copy.on || a.pick.open { + if a.pick.open { return hoverAt{} } // THE PROJECT IS ON THIS ROW ONLY AT THE PHONE TIER; everywhere else @@ -662,7 +662,7 @@ func (a *app) hoverTarget(x, y int) hoverAt { // identity's own row, then the columns the render recorded for the model. // Any of them answering differently here would be a name that brightens // and then does nothing. - if a.copy.on || a.at(pageSettings) || a.pick.open { + if a.at(pageSettings) || a.pick.open { return hoverAt{} } // AT PHONE WIDTH THE ROW IS A DECK AND THE DECK ANSWERS FOR BOTH OF ITS diff --git a/internal/tui3/input.go b/internal/tui3/input.go index abda1dfd68..8c205c8142 100644 --- a/internal/tui3/input.go +++ b/internal/tui3/input.go @@ -634,14 +634,6 @@ func (a *app) key(msg tea.KeyPressMsg) tea.Cmd { return cmd } - // COPY MODE is modal, and it is modal one rung below ctrl+c for the same - // reason everything else here is: leaving is never modal. While it is up the - // surface is a reader, and a key that fell through to the draft would type - // into a box whose effect is off screen (copymode.go). - if cmd, taken := a.copyKey(msg); taken { - return cmd - } - // REWIND MODE IS MODAL AT THE SAME RUNG AND FOR THE SAME REASON (rewind.go): // while it is up the draft box is not on the frame at all — a mode bar stands // in its position — so a key that fell through to the editor would type into a @@ -931,20 +923,11 @@ func (a *app) key(msg tea.KeyPressMsg) tea.Cmd { return nil } - case "ctrl+b": - // FREEZE AND READ (copymode.go). ctrl+b used to be the emacs `left` here, - // alongside the arrow key that everybody actually presses, and it is spent - // on this instead: the alt screen took the terminal's own selection away, - // and getting text out of the conversation is a thing this surface could - // not do at all. ← is untouched. - a.enterCopy() - return nil - case selectKey: - // DRAG THE WAY YOU DRAG EVERYWHERE ELSE (copymode.go). It is the mouse's - // half of ctrl+b and it sits beside it for that reason. With the pointer + // DRAG THE WAY YOU DRAG EVERYWHERE ELSE (clipboard.go). With the pointer // already the terminal's it does nothing, because the drag it offers is - // one the person can already make. + // one the person can already make. ctrl+b, which was copy mode's key + // beside this one until 2026-09-22, is bound to nothing now. a.releaseMouse() return nil diff --git a/internal/tui3/jobpage.go b/internal/tui3/jobpage.go index 02413002da..325315e45e 100644 --- a/internal/tui3/jobpage.go +++ b/internal/tui3/jobpage.go @@ -169,7 +169,7 @@ func (a *app) jobPageKeyPress(msg tea.KeyPressMsg) (tea.Cmd, bool) { switch key := msg.String(); { case key == "ctrl+c", a.asking(), a.awaitingTask(), a.at(pageSettings), a.at(pageTasks), a.at(pageHome), a.deckShowing(), a.pick.open, - a.roster.open, a.copy.on, a.welcome.open, a.menu.open, a.comp.open: + a.roster.open, a.welcome.open, a.menu.open, a.comp.open: return nil, false } return a.jobPageKey(msg.String()), true diff --git a/internal/tui3/jumpchip.go b/internal/tui3/jumpchip.go index 3f215453ec..5c55398a9c 100644 --- a/internal/tui3/jumpchip.go +++ b/internal/tui3/jumpchip.go @@ -67,12 +67,11 @@ func jumpLabel(pal palette) string { // conversation shorter than the window has nothing below it, and a chip offering // to jump to a row that is already on screen is a chip that does nothing. func (a *app) jumpShowing() bool { - // THE BODY REGION HAS TO BE THE CONVERSATION. A frozen viewport manages its - // own edge and rejoins it on the way out (copymode.go's [app.exitCopy]), and - // a room or the fullscreen roster has put the transcript off the frame - // entirely — a chip that offered to scroll a list nobody can see is the same - // mistake [app.rowAt] refuses to make. - if a.copy.on || a.roomOpen() || a.railFull() { + // THE BODY REGION HAS TO BE THE CONVERSATION. A room or the fullscreen + // roster has put the transcript off the frame entirely — a chip that + // offered to scroll a list nobody can see is the same mistake [app.rowAt] + // refuses to make. + if a.roomOpen() || a.railFull() { return false } height := a.viewHeight() @@ -144,8 +143,8 @@ func (a *app) jumpPress(x, y int) bool { // never mean slightly different things. // // The edge is rejoined by ARMING THE STICK rather than by computing a bottom -// offset, which is how every other path back does it (welcome.go, app.go's /new, -// copymode.go's [app.exitCopy]): the offset is resolved from the row count at +// offset, which is how every other path back does it (welcome.go, app.go's +// /new): the offset is resolved from the row count at // draw time, so a stick armed here survives the four rows the turn streams // between this keystroke and the next frame. func (a *app) toLatest() { diff --git a/internal/tui3/markdownwrap_test.go b/internal/tui3/markdownwrap_test.go index e686143efc..5e1b60eaeb 100644 --- a/internal/tui3/markdownwrap_test.go +++ b/internal/tui3/markdownwrap_test.go @@ -385,45 +385,3 @@ func TestAWrappedCodeRowIsMarkedAndAnUnwrappedOneIsNot(t *testing.T) { strings.Join(renderMarkdown("```go\n"+long+"\n```", 120), "\n")) } } - -// AND COPY MODE STILL SEES ONE BLOCK. `a` selects the run of code rows around -// the cursor, and it read that run off the gutter alone — so a wrapped line -// ENDED the run and the yank took the top half of the block. The paste carries -// the source and neither the hairline nor the marker. -func TestCopyModeTakesAWrappedFenceWholeAndPastesNoMarkers(t *testing.T) { - long := "x := " + strings.Repeat("aVeryLongIdentifier + ", 12) + "1" - rows := renderMarkdown("```go\n"+long+"\ny := 2\n```", 120) - text := make([]string, len(rows)) - for i, row := range rows { - text[i] = plain(row) - } - c := ©Mode{text: text} - - at := -1 - for i, row := range text { - if strings.Contains(row, mdContMark) { - at = i - break - } - } - if at < 0 { - t.Fatalf("nothing wrapped, so this case tests nothing:\n%s", strings.Join(text, "\n")) - } - from, to, ok := c.fenceAt(at) - if !ok { - t.Fatalf("a wrapped code row is not read as part of a fence: %q", text[at]) - } - if !strings.Contains(text[to], "y := 2") { - t.Fatalf("the block was cut at the wrapped row: rows %d-%d end on %q", from, to, text[to]) - } - var pasted []string - for _, row := range text[from : to+1] { - // These rows came straight from [renderMarkdown] and never went through - // the transcript's pass, so there is no reading gutter on them to lift. - pasted = append(pasted, copyClean(row, 0)) - } - joined := strings.Join(pasted, "") - if strings.Contains(joined, mdContMark) || strings.Contains(joined, tokens.GlyphCodeGutter) { - t.Fatalf("the paste carries the frame's own marks:\n%q", joined) - } -} diff --git a/internal/tui3/moneydoor.go b/internal/tui3/moneydoor.go index ef6d075861..2877b400d0 100644 --- a/internal/tui3/moneydoor.go +++ b/internal/tui3/moneydoor.go @@ -66,7 +66,7 @@ func (a *app) moneyPress(x, y int) tea.Cmd { // moneyDoorAt is that question on its own, because the pointer asks it too: the // set that LIGHTS has to be the set the press acts on (hover.go's own law). func (a *app) moneyDoorAt(x, y int) bool { - if a.copy.on || a.at(pageSettings) || a.pick.open { + if a.at(pageSettings) || a.pick.open { return false } mark, ok := a.chromeAt(y) diff --git a/internal/tui3/notice.go b/internal/tui3/notice.go index 1a7a10ad9a..5a1067d74f 100644 --- a/internal/tui3/notice.go +++ b/internal/tui3/notice.go @@ -117,8 +117,6 @@ const ( // eventRewound is a rewind that landed, from either surface (rewind.go's // [app.rewindLand]). eventRewound = "rewound" - // eventCopyEntered is copy mode freezing the viewport (copymode.go). - eventCopyEntered = "copy-entered" // eventModelSwitched is the conversation's model changing by any door // (palette.go's [app.switchModel]). eventModelSwitched = "model-switched" @@ -194,7 +192,7 @@ const ( // refuse a retire rule that names a word nobody fires. var noticeEvents = []string{ eventBoot, eventTurnEnded, eventTaskStarted, eventTaskPageOpened, - eventMenuOpened, eventRewound, eventCopyEntered, eventModelSwitched, + eventMenuOpened, eventRewound, eventModelSwitched, eventCompacted, eventFilesOpened, eventResumeOpened, eventCostShown, eventStandingOpened, eventDeliverableMade, eventAsked, eventTaskTyped, eventManualAsked, eventTabReopened, eventAtOpened, eventAttached, @@ -1068,7 +1066,7 @@ func (a *app) noticeHint() string { // noticeQuiet is whether nothing on the frame outranks a tip. func (a *app) noticeQuiet() bool { return a.input.empty() && a.state != stateWorking && a.showing() == nil && - !a.rew.on && !a.rewSheet.open && !a.copy.on && !a.menu.open && !a.comp.open && + !a.rew.on && !a.rewSheet.open && !a.menu.open && !a.comp.open && !a.pick.open && !a.roster.open && !a.asking() && !a.roomOpen() } diff --git a/internal/tui3/notice_test.go b/internal/tui3/notice_test.go index 9e027168a0..713b165ba9 100644 --- a/internal/tui3/notice_test.go +++ b/internal/tui3/notice_test.go @@ -644,13 +644,6 @@ func TestEveryRetireEventIsProvedByItsGesture(t *testing.T) { t.Fatal(err) } }, - eventCopyEntered: func(t *testing.T, a *app) { - a.note("something to copy") - a.enterCopy() - if !a.copy.on { - t.Fatal("copy mode did not open") - } - }, eventModelSwitched: func(t *testing.T, a *app) { a.switchModel("openai/gpt-4.1", 1_000_000) }, eventCompacted: func(t *testing.T, a *app) { drive(t, a, compactedMsg{}) }, eventFilesOpened: func(t *testing.T, a *app) { a.slash("/files") }, diff --git a/internal/tui3/payload.go b/internal/tui3/payload.go index 58a7856fe1..5fea0f8da7 100644 --- a/internal/tui3/payload.go +++ b/internal/tui3/payload.go @@ -44,7 +44,7 @@ import ( // thing wearing it is a mark that has to be read twice to learn which one it // is. A chord steps to the data hue instead, which is the same one-step move // every other datum makes and costs the budget nothing. So `/help` is chipped -// wherever it is written, `ctrl+b` wears the data hue wherever it is written, +// wherever it is written, `ctrl+s` wears the data hue wherever it is written, // and neither rule has an exception. // // ── STRATEGY OVER DECORATION ──────────────────────────────────────────────── diff --git a/internal/tui3/payload_test.go b/internal/tui3/payload_test.go index 726ca2feaf..0c219e8285 100644 --- a/internal/tui3/payload_test.go +++ b/internal/tui3/payload_test.go @@ -121,7 +121,7 @@ func TestTheKeySheetLiftsItsChordsAndChipsItsCommands(t *testing.T) { rows := noteRows(a, help, columnFacts(help, true)...) body := strings.Join(rows, "\n") - for _, chord := range []string{"ctrl+b", "ctrl+o", "alt+enter", "@path"} { + for _, chord := range []string{"ctrl+s", "ctrl+o", "alt+enter", "@path"} { if !lifted(a.pal, body, chord) { t.Fatalf("the key sheet draws %q at the weight of the sentence beside it", chord) } @@ -158,8 +158,8 @@ func TestAStatusFigureReadsAboveItsLabel(t *testing.T) { // THE COLUMN IS READ BACK OFF THE TEXT, in both directions, so the fact list and // the note cannot be two spellings of one thing that drift apart. func TestColumnFactsReadEitherHalfOfATwoColumnNote(t *testing.T) { - text := "codeaf\n\n/help what you can type\nctrl+b copy mode\nsession · x.json" - if got := columnFacts(text, true); len(got) != 2 || got[0] != "/help" || got[1] != "ctrl+b" { + text := "codeaf\n\n/help what you can type\nctrl+s drag to select\nsession · x.json" + if got := columnFacts(text, true); len(got) != 2 || got[0] != "/help" || got[1] != "ctrl+s" { t.Fatalf("the leading column is not the two keys: %q", got) } if got := columnFacts(text, false); len(got) != 2 || got[0] != "what you can type" { diff --git a/internal/tui3/place_sessions.go b/internal/tui3/place_sessions.go index 5e5a558631..d7674ddc1a 100644 --- a/internal/tui3/place_sessions.go +++ b/internal/tui3/place_sessions.go @@ -891,7 +891,7 @@ func (a *app) taskSheetKeyPress(msg tea.KeyPressMsg) (tea.Cmd, bool) { return nil, false } switch { - case a.asking(), a.awaitingTask(), a.copy.on, a.rew.on, a.rewSheet.open, a.welcome.open, + case a.asking(), a.awaitingTask(), a.rew.on, a.rewSheet.open, a.welcome.open, a.menu.open, a.comp.open, a.guarding(), a.stopping(): return nil, false } diff --git a/internal/tui3/projectseam.go b/internal/tui3/projectseam.go index a45baa5175..3ecc698fab 100644 --- a/internal/tui3/projectseam.go +++ b/internal/tui3/projectseam.go @@ -50,7 +50,7 @@ func (a *app) paintSeamProject(text string, span hudSpan, hovered bool) string { // ([app.hintRowKind]) — and it opens the folder chooser, which is what the // word is a door onto: the same sheet `/folder` opens. func (a *app) seamProjectPress(x, y int) (tea.Cmd, bool) { - if a.copy.on || a.pick.open || a.roomOpen() || !a.seamProjectSpan.holds(x) { + if a.pick.open || a.roomOpen() || !a.seamProjectSpan.holds(x) { return nil, false } mark, ok := a.chromeAt(y) diff --git a/internal/tui3/question.go b/internal/tui3/question.go index 87c14088ab..f20f0d8d02 100644 --- a/internal/tui3/question.go +++ b/internal/tui3/question.go @@ -3959,7 +3959,7 @@ func (a *app) openQuestionRoom(head questionShown) tea.Cmd { // through to whatever is under it, exactly as a key does. func (a *app) questionPress(x, y int) (tea.Cmd, bool) { head, ok := a.questionHead() - if !ok || a.copy.on { + if !ok { return nil, false } set := a.questionSet() diff --git a/internal/tui3/render.go b/internal/tui3/render.go index d28e9cd5ef..7a5e4b9375 100644 --- a/internal/tui3/render.go +++ b/internal/tui3/render.go @@ -2956,7 +2956,7 @@ func (a *app) stateSegment() (string, string) { if a.room != nil && !a.orchOpen() { return word, painted } - if a.state != stateWorking || a.asking() || a.copy.on { + if a.state != stateWorking || a.asking() { return word, painted } mark := tokens.Spinner(a.paints / spinnerStep) @@ -3089,16 +3089,6 @@ func (a *app) warmSegmentShort() string { // it is waiting for them. "your call" rather than "your answer" because it is // shorter and because it is what it is. func (a *app) stateWord() (string, string) { - // COPY OUTRANKS EVERYTHING, because it is the only state on this line that is - // about the KEYBOARD rather than about the turn. While the viewport is frozen - // the keys do something else entirely (copymode.go), and a status line that - // said "idle" would be describing the session correctly and the screen - // wrongly. The turn underneath keeps running; the row it would have claimed - // is back the moment esc is pressed. - if a.copy.on { - word := a.copyWord() - return word, a.pal.accent(word) - } // A sweep's receipt outranks the run state for the seconds it stands: the // person's eye is on the status line asking exactly one question — did the // copy land — and the turn's own word is back the moment it expires @@ -3841,8 +3831,6 @@ func (a *app) hintWord() string { // one line saying "enter" for both would be teaching nobody // (subharness.go). return a.subVerbs() - case a.copy.on: - return copyKeysWord case a.rew.on: // The rewind mode prints its own keys in the bar that replaced the draft // box (rewind.go), and a slot repeating them would be the surface saying diff --git a/internal/tui3/rewind.go b/internal/tui3/rewind.go index 00f98efffa..d9fcb1d662 100644 --- a/internal/tui3/rewind.go +++ b/internal/tui3/rewind.go @@ -138,7 +138,7 @@ func (a *app) enterRewind() tea.Cmd { // The frame stack decides where this can be opened from: a room, a frozen // viewport and the fullscreen panels all draw over the place the mode bar // stands in, and a bar nobody can see is a mode nobody can leave (view.go). - if a.rew.on || a.rewSheet.open || a.copy.on || a.roomOpen() || a.at(pageSettings) || a.railFull() { + if a.rew.on || a.rewSheet.open || a.roomOpen() || a.at(pageSettings) || a.railFull() { return nil } agent, ok := a.rewinder() diff --git a/internal/tui3/rewindsheet.go b/internal/tui3/rewindsheet.go index 10ea5d864a..ef2f5b1b29 100644 --- a/internal/tui3/rewindsheet.go +++ b/internal/tui3/rewindsheet.go @@ -191,7 +191,7 @@ type rewindSheet struct { // reached from inside the inline mode, which is why that state is handled by the // lift below instead of being refused here. func (a *app) openRewindSheet() tea.Cmd { - if a.rewSheet.open || a.rew.on || a.copy.on || a.roomOpen() || a.at(pageSettings) || a.railFull() { + if a.rewSheet.open || a.rew.on || a.roomOpen() || a.at(pageSettings) || a.railFull() { return nil } agent, ok := a.rewinder() diff --git a/internal/tui3/rewindsheet_test.go b/internal/tui3/rewindsheet_test.go index db696f7eb0..a556143897 100644 --- a/internal/tui3/rewindsheet_test.go +++ b/internal/tui3/rewindsheet_test.go @@ -137,7 +137,6 @@ func TestTheTimelineRefusesWhereTheInlineModeDoes(t *testing.T) { name string hold func(*app) }{ - {"copy mode", func(a *app) { a.copy.on = true }}, {"the settings panel", func(a *app) { a.raisePlace(pageSettings) }}, {"the inline rewind", func(a *app) { runCmd(a.enterRewind()) }}, } { diff --git a/internal/tui3/room.go b/internal/tui3/room.go index 141357dc59..30532b2ad9 100644 --- a/internal/tui3/room.go +++ b/internal/tui3/room.go @@ -1866,7 +1866,7 @@ func (a *app) roomKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { switch key := msg.String(); { case key == "ctrl+c", a.asking(), a.awaitingTask(), a.at(pageSettings), a.at(pageTasks), a.at(pageHome), a.deckShowing(), a.pick.open, - a.roster.open, a.copy.on, a.welcome.open, a.menu.open, a.comp.open, + a.roster.open, a.welcome.open, a.menu.open, a.comp.open, a.effPick.open: return nil, false } @@ -1957,13 +1957,6 @@ func (a *app) roomKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { a.cycleTaskEffort() return nil, true - case "ctrl+b": - // FREEZE THE ROOM, not the conversation. copymode.go snapshots the - // transcript's rows, which while a room is open are not the rows on - // screen — so the snapshot is taken here, from what is actually drawn. - a.freezeRoom() - return nil, true - case "pgup": a.roomScroll(-a.scrollPage()) return nil, true @@ -2061,46 +2054,6 @@ func (a *app) roomHint() string { return "" } -// freezeRoom hands copy mode the room's own rows. It is the same frozen viewport -// [app.enterCopy] builds — same struct, same cursor, same yank — over a -// different list, because what a person freezes must be what a person is -// reading. -// -// ONE WRINKLE, KNOWN AND SMALL: leaving copy mode rejoins the CONVERSATION's -// live edge ([app.exitCopy] sets stick), so a person who froze a room while the -// transcript was scrolled up loses that scroll. It is the one seam where the -// room does not leave the conversation untouched, and it is left alone because -// the alternative — a room-shaped exception inside copy mode — would put a -// second definition of "what is frozen" in a file whose whole point is that -// there is one. -func (a *app) freezeRoom() { - if a.copy.on || a.room == nil { - return - } - rows := a.roomRows(a.bodyWidth()) - if len(rows) == 0 { - return - } - height := a.viewHeight() - snapshot := make([]string, 0, len(rows)) - stripped := make([]string, 0, len(rows)) - owner := make([]int, 0, len(rows)) - for _, r := range rows { - snapshot = append(snapshot, r.text) - stripped = append(stripped, ansi.Strip(r.text)) - // The index is into the ROOM's own list, which is the list this snapshot - // was taken from — that is all "a" needs it to be, since it only ever - // compares two rows of the same freeze (copymode.go's [app.copyBlock]). - owner = append(owner, r.entry) - } - top := a.roomOffsetFor(len(rows), height) - a.copy = copyMode{ - on: true, rows: snapshot, text: stripped, owner: owner, - at: min(top+height-1, len(rows)-1), top: top, mark: -1, - } - a.touch() -} - // ── moving between the conversation and the work ──────────────────────────── // // THE ARROWS ARE THE OTHER DOOR. The rail's click opens a room and esc leaves diff --git a/internal/tui3/roomscroll_test.go b/internal/tui3/roomscroll_test.go index be6f25fb0e..e54145833b 100644 --- a/internal/tui3/roomscroll_test.go +++ b/internal/tui3/roomscroll_test.go @@ -241,40 +241,3 @@ func TestAShorterFrameFoldsARoomToItsNewHeight(t *testing.T) { calls, shorter) } } - -// FREEZING A FOLDED ROOM STAYS IN BOUNDS. ctrl+b snapshots the room-sized rows, -// and every cursor the copy keys move lands inside them. -func TestFreezingAFoldedRoomKeepsTheCopyCursorInBounds(t *testing.T) { - a, _ := callsRoom(t) - width, height := a.bodyWidth(), a.viewHeight() - rows := a.roomRows(width) - - a.freezeRoom() - if !a.copy.on { - t.Fatal("ctrl+b did not freeze the room") - } - if len(a.copy.rows) != len(rows) { - t.Fatalf("the freeze holds %d rows of a %d-row page", len(a.copy.rows), len(rows)) - } - inBounds := func(when string) { - t.Helper() - if a.copy.at < 0 || a.copy.at >= len(a.copy.rows) { - t.Fatalf("%s: the copy cursor is at %d of %d rows", when, a.copy.at, len(a.copy.rows)) - } - if a.copy.top < 0 || a.copy.top > max(len(a.copy.rows)-height, 0) { - t.Fatalf("%s: the copy window starts at %d of %d rows", when, a.copy.top, len(a.copy.rows)) - } - } - inBounds("on freeze") - if a.copy.top != a.roomOffsetFor(len(rows), height) { - t.Fatalf("the freeze starts at %d, the room was at %d", a.copy.top, a.roomOffsetFor(len(rows), height)) - } - for _, k := range []string{"home", "a", "end", "v", "pgup", "pgdown"} { - drive(t, a, key(k)) - inBounds("after " + k) - } - drive(t, a, key("esc")) - if a.copy.on { - t.Fatal("esc did not leave copy mode") - } -} diff --git a/internal/tui3/roomstatus_test.go b/internal/tui3/roomstatus_test.go index 9aeda463b7..13eff705c8 100644 --- a/internal/tui3/roomstatus_test.go +++ b/internal/tui3/roomstatus_test.go @@ -56,11 +56,6 @@ func TestTaskFooterKeepsPhaseAndKeyboardFeedback(t *testing.T) { if got, _ := a.stateSegment(); got != "awaiting your look" { t.Fatalf("task phase lost: %q", got) } - a.copy.on = true - if got, _ := a.stateSegment(); got != a.copyWord() { - t.Fatalf("copy keyboard feedback lost: %q", got) - } - a.copy.on = false a.roomNode().stopped = true if got, _ := a.stateSegment(); got != stoppingWord { t.Fatalf("stopped task still claims work: %q", got) diff --git a/internal/tui3/slotfilter_test.go b/internal/tui3/slotfilter_test.go index 8136e58f25..096c47af57 100644 --- a/internal/tui3/slotfilter_test.go +++ b/internal/tui3/slotfilter_test.go @@ -170,32 +170,26 @@ func TestASlotWithNoCandidateSaysSo(t *testing.T) { // ── 3. the hint slot follows the KEYBOARD ─────────────────────────────────── // THE ORDER IS input.go's ROUTING ORDER. A hint is only true if it names the -// keys the handler that reads first would take, and copy mode is the state this -// slot was most wrong about: every key means something else while the viewport -// is frozen, and the slot was drawing the input box's own two affordances. +// keys the handler that reads first would take. func TestTheHintSlotFollowsTheKeyboard(t *testing.T) { a := newTestApp(&fakeAgent{model: "openai/gpt-4.1-mini"}) - a.copy.on = true - if got := a.hintWord(); got != "v select · a block · y yank · esc" { - t.Fatalf("copy mode offered %q", got) - } - // And the handed-over pointer leads even copy mode, because the key that - // ends it is read above everything (input.go's [app.key]). + // The handed-over pointer leads, because the key that ends it is read + // above everything (input.go's [app.key]). a.released = true if got := a.hintWord(); got != "drag to select · any key ends it" { t.Fatalf("a handed-over pointer offered %q", got) } a.released = false - // The picker is read ABOVE copy mode (input.go reads it before ctrl+c), so - // it wins the slot when both are somehow up. + // The picker is read above the plain switch (input.go reads it before + // ctrl+c), so it wins the slot. a.pick.open = true // And the crew rides the end of it on any launch that has one (crew.go's // [app.crewHint]). if got := a.hintWord(); got != "enter switch · esc · crew "+config.DefaultCrew { t.Fatalf("an open picker offered %q", got) } - a.pick.open, a.copy.on = false, false + a.pick.open = false a.menu.open = true if got := a.hintWord(); got != "↑↓ · enter · esc" { diff --git a/internal/tui3/spellout.go b/internal/tui3/spellout.go index e067862791..185de61b8a 100644 --- a/internal/tui3/spellout.go +++ b/internal/tui3/spellout.go @@ -197,7 +197,7 @@ func (a *app) spellOffered() bool { if a.spellShowing() { return false } - if a.copy.on || a.rew.on || a.roomOpen() { + if a.rew.on || a.roomOpen() { return false } if _, ok := a.spellDoor(); !ok { diff --git a/internal/tui3/spellout_test.go b/internal/tui3/spellout_test.go index 318dcebcad..faca4a4e27 100644 --- a/internal/tui3/spellout_test.go +++ b/internal/tui3/spellout_test.go @@ -319,16 +319,11 @@ func TestABuildWithNoExpanderNeverOffersTheChord(t *testing.T) { } } -// The block is never up while the pointer is somewhere else on the surface — it -// belongs to the draft, and a page that replaced the draft took it with them. -func TestTheBlockIsNotOfferedInCopyMode(t *testing.T) { +// The block is never up while a turn is running — it belongs to the draft at +// rest, and a running answer took the keys with it. +func TestTheBlockIsNotOfferedWhileATurnRuns(t *testing.T) { a, _ := spellLab(t) typeDraft(t, a, "build me a login page") - a.copy.on = true - if a.spellOffered() { - t.Fatal("the chord was offered while the viewport was frozen") - } - a.copy.on = false a.state = stateWorking if a.spellOffered() { t.Fatal("the chord was offered while a turn was running") diff --git a/internal/tui3/steer.go b/internal/tui3/steer.go index 1c67030f19..e8d820bc99 100644 --- a/internal/tui3/steer.go +++ b/internal/tui3/steer.go @@ -200,7 +200,7 @@ func (a *app) steerAvailable() bool { // the keyboard outright, and the rail holds it while the roster is up. Every // one of these is read above the plain switch in [app.key], so the guard is // here for the HINT's sake as much as the key's. - return !a.roomOpen() && !a.copy.on && !a.rew.on && !a.railHold + return !a.roomOpen() && !a.rew.on && !a.railHold } // steerOffered reports whether plain enter may be named as a steer — which is @@ -453,7 +453,7 @@ func (a *app) runSendOffered() bool { if a.state != stateWorking || a.input.empty() && len(a.chips) == 0 { return false } - return !a.roomOpen() && !a.copy.on && !a.rew.on && !a.railHold + return !a.roomOpen() && !a.rew.on && !a.railHold } // runHint is the one line while a turn runs. Its order follows the hand across diff --git a/internal/tui3/steer_test.go b/internal/tui3/steer_test.go index 3b64e4ef94..fcd9ef86c8 100644 --- a/internal/tui3/steer_test.go +++ b/internal/tui3/steer_test.go @@ -548,33 +548,6 @@ func TestAFallenThroughCorrectionIsOnTheScreenExactlyOnce(t *testing.T) { } } -// ── the overlays above ────────────────────────────────────────────────────── - -// AN OVERLAY THAT HAS TAKEN THE KEYBOARD KEEPS BOTH KEYS. Copy mode is read -// above the plain switch, and while it is up the surface is a reader: a chord -// that reached past it would send a sentence out of a box nobody is looking at. -func TestAnOverlayAboveKeepsBothSteerKeys(t *testing.T) { - a, agent := steerableTurn(t, "reading the tree. ") - parkLine(t, a, "do much more of a deep research please") - a.input.setText("no, the other file") - a.enterCopy() - if !a.copy.on { - t.Fatal("copy mode did not open") - } - - drive(t, a, wirePress(t, "\x1b[13;9u", steerKeySuper), key("right")) - if len(agent.steered) != 0 { - t.Fatalf("a key reached past copy mode and steered: %q", agent.steered) - } - if len(a.parks) != 1 { - t.Fatalf("a key reached past copy mode and moved the queue: %+v", a.parks) - } - // And the slot names neither, because neither would do anything. - if got := a.hintWord(); strings.Contains(got, parkKey) || strings.Contains(got, steerSendWord) { - t.Fatalf("the hint named the steer while copy mode held the keyboard: %q", got) - } -} - // ── the two lines that teach it ───────────────────────────────────────────── // H2 and H3: the one running-turn line names every deliverable key in its fixed diff --git a/internal/tui3/stop.go b/internal/tui3/stop.go index b5077be5e9..efbd962b32 100644 --- a/internal/tui3/stop.go +++ b/internal/tui3/stop.go @@ -601,7 +601,7 @@ func (a *app) stopKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { case key == "ctrl+c", a.asking(), a.awaitingTask(), a.guarding(), a.taskSheet.planOn, a.railPlanPending.id != "", a.at(pageSettings), a.at(pageTasks), a.at(pageHome), a.deckShowing(), a.pick.open, - a.roster.open, a.copy.on, a.welcome.open, a.menu.open, a.comp.open, + a.roster.open, a.welcome.open, a.menu.open, a.comp.open, a.rew.on, a.rewSheet.open: return nil, false } @@ -643,7 +643,7 @@ const stopRaiseKey = "x" // took it. Two targets, in the order they are stacked on screen: the card's own // answers while it is up, and the ✕ in the room's header. func (a *app) stopPress(x, y int) bool { - if a.copy.on || a.rew.on { + if a.rew.on { return false } if a.stopping() { diff --git a/internal/tui3/task.go b/internal/tui3/task.go index 4ab2078565..cecf55ab74 100644 --- a/internal/tui3/task.go +++ b/internal/tui3/task.go @@ -3854,7 +3854,7 @@ func (a *app) railKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { key := msg.String() switch { case key == "ctrl+c", a.asking(), a.awaitingTask(), - a.at(pageSettings), a.at(pageTasks), a.at(pageHome), a.pick.open, a.copy.on, + a.at(pageSettings), a.at(pageTasks), a.at(pageHome), a.pick.open, a.welcome.open, a.menu.open, a.comp.open, a.effPick.open: return nil, false } diff --git a/internal/tui3/taskstrip.go b/internal/tui3/taskstrip.go index edb0bf0dee..7dab76b40c 100644 --- a/internal/tui3/taskstrip.go +++ b/internal/tui3/taskstrip.go @@ -504,7 +504,7 @@ func (a *app) stripTitle(node *taskNode, title string) string { // reading them first would be reading where the chips were drawn on the frame // before this one. func (a *app) stripPress(x, y int) (tea.Cmd, bool) { - if a.at(pageSettings) || a.copy.on || a.welcome.open { + if a.at(pageSettings) || a.welcome.open { return nil, false } width, _ := a.size() @@ -557,7 +557,7 @@ func (a *app) stripPress(x, y int) (tea.Cmd, bool) { // are not in disagreement: the row eats the miss so it cannot fall through to the // conversation, and a gap that brightened would be claiming to be a door. func (a *app) stripHoverAt(x, y int) (hoverAt, bool) { - if a.at(pageSettings) || a.copy.on || a.welcome.open || y != a.headHeight() { + if a.at(pageSettings) || a.welcome.open || y != a.headHeight() { return hoverAt{}, false } width, _ := a.size() diff --git a/internal/tui3/taskview.go b/internal/tui3/taskview.go index 51bb949915..88b5dbde17 100644 --- a/internal/tui3/taskview.go +++ b/internal/tui3/taskview.go @@ -47,8 +47,8 @@ import ( // // ctrl+. IS THE LAST OBVIOUS CHORD AND IT IS SPENT DELIBERATELY. Every // ctrl+ this surface could reach for is taken — the readline edits the -// message box answers without looking, the roster's alt+t, the column's ctrl+g, -// copy mode's ctrl+b — and the four letters that are free are documented as NOT +// message box answers without looking, the roster's alt+t, the column's ctrl+g +// — and the four letters that are free are documented as NOT // BOUND, which is a promise a person has read. What is left is the punctuation // pair, and the pair is the point: ctrl+, opens the settings panel and ctrl+. // opens this one, two adjacent keys for the two fullscreen pages. A terminal diff --git a/internal/tui3/tui3_test.go b/internal/tui3/tui3_test.go index 4a7283d493..bf5d58b79d 100644 --- a/internal/tui3/tui3_test.go +++ b/internal/tui3/tui3_test.go @@ -820,7 +820,7 @@ func newTestAppWithProfile(profileDir string, agent Agent) *app { // AND IT PINS THE TERMINAL, which is the second and third pin in one // table, for exactly the reason the palette is pinned below: [newApp] // reads the environment to decide whether a clipboard write needs the - // multiplexer's passthrough wrapper (copymode.go), how often the frame + // multiplexer's passthrough wrapper (clipboard.go), how often the frame // clock turns over a link (link.go), and whether a path may be written // as an OSC 8 link at all (pathlink.go) — so a suite run inside tmux got // the wrapped yank, a suite run over ssh stepped every animation three diff --git a/internal/tui3/view.go b/internal/tui3/view.go index 08ef81ace1..67b04b1a67 100644 --- a/internal/tui3/view.go +++ b/internal/tui3/view.go @@ -1109,9 +1109,6 @@ func (a *app) bodyRows(width, height int) ([]row, int) { // row is what says which page it belongs to. return []row{{text: a.pal.dim(fit(startTinyWord, width)), entry: -1}}, max(0, height-1) } - if a.copy.on { - return a.copyRows(width, height) - } // AND A QUESTION OPENED OUT INTO ITS OWN PAGE IS THE FOURTH ANSWER, on the // room's own terms and above it (questionroom.go): a question is drawn over // whatever it was raised about, and a node's page is one of the things it can From 25dd9455a725a6a98cb2bae3ee6364107fbee85f Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:43:28 -0400 Subject: [PATCH 09/39] chat: the tip cross moves the row on, /project takes /folder's home half MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cross on a tip row meant "put this away" and did two things nobody asked for: it counted the standing as a showing, and it left the row blank until the slot next changed hands — so on home the same tip was back on the next visit with one of its six showings gone. It now means NOT THIS ONE: the row rotates to the next tip on the very next frame, and the tip put away is charged nothing and comes round again. Only with one eligible tip left does the row go blank, as it always did. /folder meant two different things depending on which screen it was typed on: give THIS conversation a folder, or pin the folder the NEXT conversation opens in. The pin is /project now, home's alone — `/project ` takes the path and opens nothing, a bare /project opens the browser aimed at the target, and in a conversation it says which screen it lives on. /folder means one thing everywhere, so on home it opens a conversation first like /files and /compact. Also: every door onto the context sheet fires its own notice event rather than the sheet deciding off the folder-door flag, which used to retire both /attach tips when somebody opened the sheet to pick a project. The table is thirty-one hints: /project's is armed on home alone. Co-Authored-By: Claude Opus 5 --- internal/manual/chat/attaching-files.md | 12 +- internal/manual/chat/choosing-a-folder.md | 49 ++++-- internal/manual/chat/commands.md | 34 +++- internal/manual/chat/hints-and-tips.md | 24 ++- internal/manual/chat/home.md | 33 ++-- internal/manual/chat/keys.md | 2 +- internal/manual/chat/screen.md | 4 +- internal/manual/chat_test.go | 9 + internal/tui3/app.go | 10 ++ internal/tui3/commands.go | 7 + internal/tui3/contextmodal_test.go | 4 +- internal/tui3/foldercontext_test.go | 4 +- internal/tui3/folderpick.go | 2 +- internal/tui3/folderplace.go | 53 +++--- internal/tui3/home.go | 5 +- internal/tui3/homefate_test.go | 125 ++++++++++++-- internal/tui3/homeslash.go | 24 ++- internal/tui3/homeslash_test.go | 15 +- internal/tui3/hometip_test.go | 199 +++++++++++++++++++++- internal/tui3/hometiplayout_test.go | 27 ++- internal/tui3/notice.go | 98 ++++++++--- internal/tui3/notice_test.go | 11 +- internal/tui3/projectcmd.go | 94 ++++++++++ internal/tui3/projectseam.go | 7 +- 24 files changed, 703 insertions(+), 149 deletions(-) create mode 100644 internal/tui3/projectcmd.go diff --git a/internal/manual/chat/attaching-files.md b/internal/manual/chat/attaching-files.md index 207767cbb2..db4b7237f0 100644 --- a/internal/manual/chat/attaching-files.md +++ b/internal/manual/chat/attaching-files.md @@ -449,13 +449,13 @@ it: the chip appears above home's box, home says first message of whatever conversation you start next. A picture goes the same way, and a drop or a paste onto home does it with no command at all. -**A bare `/attach` there opens the browser**, the same sheet a bare `/folder` opens, aimed at -the folder the next conversation opens in; a file chosen on it lands on home's tray and a +**A bare `/attach` there opens the browser**, the same sheet a bare `/project` opens, aimed +at the folder the next conversation opens in; a file chosen on it lands on home's tray and a folder chosen on it becomes that folder. (Until 2026-09-22 it answered `type the path after -/attach · or drop the file -here` — rather than opening the browser. `/folder` is the browser on local home, and it is -aimed at which folder the next conversation opens in (see "Choosing a folder"). Over -`--host`, `/folder` says why this machine's folder cannot be that far conversation's folder. +/attach · or drop the file here` — rather than opening the browser.) `/project` is the +browser on local home, and it is aimed at which folder the next conversation opens in (see +"Choosing a folder"); `/folder` on home opens a conversation first and browses there. Over +`--host`, both say why this machine's folder cannot be that far conversation's folder. **The tray survives the walk.** Attach a file on home, go into a conversation, come back: it is still there. Home's tray row cannot be clicked; a chip comes off on a conversation's own diff --git a/internal/manual/chat/choosing-a-folder.md b/internal/manual/chat/choosing-a-folder.md index a89cf63f12..7bb6a908c3 100644 --- a/internal/manual/chat/choosing-a-folder.md +++ b/internal/manual/chat/choosing-a-folder.md @@ -80,8 +80,9 @@ directory you started it in is the one the status line shows and the one a bare `notes.md` means, for the life of the conversation. `## What choosing a folder actually does` below has the whole of that distinction. -To start a new conversation already pointed at a project, `/folder` on the home -screen picks the folder the next one opens in. +To start a new conversation already pointed at a project, `/project` on the home +screen picks the folder the next one opens in — `/project ~/code/parser`, or bare +for the browser. ## The add context sheet over --host — another machine, over ssh @@ -103,16 +104,37 @@ can unchoose it or choose a file instead. `/folder`, `/place` and `/dir` say the without opening the sheet. A bare `/attach` does open because it is a file door and files travel over ssh. -## /folder on the home screen — choosing the folder the next conversation opens in +## /project — the folder the next conversation opens in, choosing the project on home, what happened to /folder on the home screen -**On local home the same command opens the same sheet, aimed at a conversation that does not -exist yet.** Home's box is a draft for the conversation `enter` will open, and the right end -of the keys row under it says where that will be: `project: ~/src/parser`. -`/folder`, `/place` and `/dir` typed there — bare, or with a path after them — open the -browser to change that folder. Over `--host`, they do not open it: this machine's directory -cannot be the far conversation's folder, so they say the refusal in the section above. +**`/project` is home's command for the folder the conversation you are about to start will +open in.** Home's box is a draft for the conversation `enter` will open, and the right end +of the keys row under it says where that will be: `project: ~/src/parser`. It has two +forms: -Three things are different on that sheet, and they all come from the same fact: +``` +/project the browser, opened where the next conversation would open +/project ~/src/parser sets it to that folder at once, with no browser +``` + +A path that is not a folder on this machine is refused by name — `no folder there · +~/src/parsr` — and nothing is pinned. A folder that is there is taken at once: home says +`project · ~/src/parser` and the keys row under the box changes on the very next frame. +Over `--host` neither form works: this machine's directory cannot be the far +conversation's folder, so it says the refusal in the section above. + +**`/folder` on home is not this command.** Until 2026-09-22 it was — `/folder` on home +pinned the next conversation's folder while `/folder` in a conversation gave THAT +conversation a folder, which is two acts behind one word. `/folder` now means one thing +everywhere: give this conversation a folder. Typed on home it opens a conversation at the +target first and browses there, like `/files` and `/compact` do. `/place` and `/dir` +follow it. Typed in a conversation, `/project` answers, exactly: + +``` +/project is home's · it sets the folder the next conversation opens in · /folder gives this conversation one +``` + +Three things are different on the sheet a bare `/project` opens, and they all come from the +same fact — the conversation it is choosing for does not exist yet: - The title reads **`the next conversation's folder`** instead of `add context`. - The action row reads **`open the next conversation in · ~/src/parser`** instead of @@ -128,7 +150,7 @@ onto the tray, which rides into the conversation home opens next. **`alt+p` is the same pin without the browser** — it walks the target round the projects this machine knows, one press at a time. The browser is what you want when the folder is not one -of those. +of those, and `/project ` is what you want when you already know where it is. ## Type a word to filter, open a row to browse @@ -709,6 +731,7 @@ choosing a folder is not available over --host yet — the folders here are this this conversation cannot be given a folder · it has no way to remember one, so nothing would reach the next request no folder matches · type a path to browse no such folder · +no folder there · nothing below here this folder cannot be read · permission denied this folder is no longer here @@ -739,6 +762,10 @@ putting changes into a folder is not available over --host yet — the conversat your own tree instead. - The third is a search that matched none of the known folders. The folder may still be there; the picker only ranks what it has seen, so type its path. +- `no folder there · ` is `/project ` on home handed something that is not a + directory on this machine — a typo, a file, or somewhere that has moved. It names the + path exactly as you typed it, because the resolved form is not what you can see to + correct, and nothing is pinned. - The fourth is the add action on a row whose folder has since been moved or deleted. The rows come from memory, and one stat at that moment is what catches it. - `nothing below here` is a folder that was read and has nothing inside it at all. You can diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index b4cef9c109..e1519b0263 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -162,8 +162,10 @@ Canonical word, the other words it answers to, its argument form, and what it do | `/resume` | `/sessions` | — | opens the earlier-conversations picker | | `/compact` | — | — | summarizes the conversation now | | `/home` | — | — | every project and conversation on this machine, fullscreen | -| `/folder` | `/place`, `/dir` | — | locally opens the add context sheet; over `--host` says the folder chooser is unavailable | +| `/folder` | `/place`, `/dir` | — | locally opens the add context sheet for THIS conversation; on home it opens a conversation first; over `--host` says the folder chooser is unavailable | | `/folder` | `/place`, `/dir` | `` | locally opens it with that in the box; over `--host` gives the same refusal | +| `/project` | — | — | on home: the browser, opened where the next conversation would open; in a conversation it says it is home's | +| `/project` | — | `` | on home: sets the folder the next conversation opens in, with no browser | | `/attach` | `/upload` | — | opens the add context sheet for files, including over `--host`; enter on this row of the `/` list opens it at once | | `/attach` | `/upload` | `` | a file goes on the tray; locally a folder is referred, while over `--host` it is refused | | `/land` | — | — | says what has been changed for a folder you chose and is waiting to go into it | @@ -399,6 +401,36 @@ whole. Typing it now answers `there is no command called /copy · / lists them`. text out of the conversation, drag across it with the mouse, or press `ctrl+s` and let your terminal select (the keys page, *Selecting text with your mouse*). +## /project — set the project on home, which folder will my next conversation open in, change the project + +`/project` is **home's** command, and it sets the folder the conversation you start next +will open in — the `project: ~/src/parser` at the right end of the keys row under home's +box. Bare, it opens the folder browser where that next conversation would open. With a +path after it, it takes the path and opens nothing: + +``` +/project the browser +/project ~/src/parser pinned at once · home says `project · ~/src/parser` +``` + +A path that is not a directory on this machine is refused by name — `no folder there · +~/src/parsr` — and nothing changes. Over `--host` it refuses: the folders this program can +read are the laptop's and the conversation would be on the other machine. + +**In a conversation it does nothing but say where it lives**, exactly: + +``` +/project is home's · it sets the folder the next conversation opens in · /folder gives this conversation one +``` + +The two commands are one word apart and do different jobs, so the answer names both. + +**It was the home half of `/folder` until 2026-09-22.** `/folder` meant "give this +conversation a folder" in a conversation and "pin the next conversation's folder" on home, +which is two acts behind one word. The pin is `/project` now, and `/folder` means the one +thing on every screen — on home it opens a conversation first and browses there. `alt+p` +is the same pin without a browser, walking the projects this machine knows. + ## /select — drag to select with your mouse You usually do not need this any more: **dragging over the conversation already diff --git a/internal/manual/chat/hints-and-tips.md b/internal/manual/chat/hints-and-tips.md index 38f5d555aa..394b380886 100644 --- a/internal/manual/chat/hints-and-tips.md +++ b/internal/manual/chat/hints-and-tips.md @@ -6,7 +6,8 @@ The dim sentence directly above the rule over your message box, led by a bulb an by a small cross — `💡 ctrl+. sees every task this project has run ✕` — is a **tip**: one line naming a key or a command you have not used yet, and what it does. It reads the way every hint on this surface does: the key or the command first, then what it does. Home has -the same row over its own box, and the two rows draw from **one list** of thirty tips (below). +the same row over its own box, and the two rows draw from **one list** of thirty-one tips +(below). **In a conversation the row appears only once you have been quiet for a minute** — no key pressed and no answer landing for sixty seconds — so it never talks over you while you type @@ -15,10 +16,13 @@ again. Left alone, the row moves on to the next tip every two minutes. On home t there from the first minute, moves on every time you come to home and every two minutes at rest, and goes blank while the box is being typed into or a list is up. -**The small cross after the tip puts it away**: click it and the row is blank until it -next changes hands — the next visit to home, the row's next two-minute turn, a quiet minute -in a conversation. Putting a tip away does not retire it. A tip never takes a row of its -own: it stands on the blank row that separates the conversation (or home's list) from the +**The small cross after the tip means NOT THIS ONE**: click it and the row moves on to the +next tip in the rotation, on the very next frame. The tip you put away is **not** spent — +it keeps its whole allowance, nothing is written down, and it comes round again another +time. Only when there is nothing else true to say does the row go blank instead, until it +next changes hands. Until 2026-09-22 the cross always blanked the row AND counted the tip +as shown, so the same tip was back on the next visit with one of its six showings gone. +A tip never takes a row of its own: it stands on the blank row that separates the conversation (or home's list) from the rule, and never blocks a keystroke. The keys row at the very foot — `alt+e effort · alt+a approvals · / commands` — is not a tip and never changes; until 2026-09-22 the tip stood there in a conversation, and it moved up to the row over the rule so both boxes say their @@ -36,7 +40,8 @@ list or a reply being read all take the row back — and it moves on to the next true for you on every road home (`esc` from a conversation, `/home`, `alt+1`, `tab`), in a fixed order, round and round. Left at rest, it moves on by itself after two minutes; a home nobody is looking at (the box being typed into, a list up) does not age, because what has -not been read has not been shown. The cross at its end puts it away until the next visit. +not been read has not been shown. The cross at its end moves the row on to another tip and +costs the one you put away nothing. ## Why did the hint disappear — each tip retires once you use what it teaches @@ -83,8 +88,8 @@ later when the ring comes round. It jumps once and then takes its turn like the ## Every hint codeaf can show, and what makes each one go away -There are thirty, one list for both boxes. Each one says the moment it first appears and -the gesture that retires it. The list is the program's own table (the surface refuses to +There are thirty-one, one list for both boxes. Each one says the moment it first appears +and the gesture that retires it. The list is the program's own table (the surface refuses to build if the two disagree), so a tip you saw is on it word for word. **Starting work** @@ -121,6 +126,9 @@ build if the two disagree), so a tip you saw is on it word for word. opens. - `/attach sends a file along with your message` — retired when a file goes on the tray by path or the file browser opens. +- `/project sets the folder the next conversation opens in` — on home only, since that is + the only screen `/project` works on. Retired when `/project` takes a folder, by a path + after it or on the browser it opens. - `/folder picks the folder codeaf works in` — retired when the folder chooser opens, from a conversation or aimed at home's target. - `/attach takes a picture too, or paste a screenshot in` — retired by the same gesture as diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index da622423a7..4801fc1a70 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -1256,11 +1256,11 @@ one behind your back. This is every fate, in the words the drop-up draws them in | The words on the row | What you type | What happens | | --- | --- | --- | | **`pins the next conversation's model`** | `/model` · `/model ` | The list opens in home's own body; the pinned model appears on the rule above the box. Nothing behind home is touched. | -| **`next conversation's folder`** | `/folder` `/place` `/dir` · `/folder ` | Opens the folder browser, **aimed at the next conversation**. Picking a folder pins it — `project: ~/src/parser` on the seam above the box shows the selection, with no duplicate footer message. | +| **`next conversation's folder`** | `/project` · `/project ` | Bare, opens the folder browser **aimed at the next conversation**; picking a folder pins it, with no duplicate footer message. With a path, pins that folder at once and opens nothing, saying `project · ~/src/parser`. Either way `project: ~/src/parser` at the right of the keys row shows the selection. | | **`opens the page`** | `/settings` `/set` `/config` · `/home` · `/search` · `/spend` · `/standing` · `/memory` `/memories` · `/history` · `/task` (bare) | A place replaces a place, exactly as before. | | **`this list is /resume`** | `/resume` `/sessions` | Says `this list is /resume · enter opens a row` — home *is* that list. | | **`onto home's tray`** | `/attach ` | The file — or picture — rides on home's own tray into the conversation you open next. Home says `attached · notes.md · rides with the next conversation`. A bare `/attach` opens the browser aimed at the next conversation's folder, and a file chosen there lands on the tray. | -| **`opens a conversation here first`** | `/files` · `/manual` · `/crew` (bare) · `/permissions` `/perms` · `/connect` · `/harness` · `/subharness` · `/select` · `/rewind` `/undo` `/back` · `/compact` · `/export` `/save` · `/standing ` · `/task ` | Opens a conversation at the target — the folder and model on the rule above the box — then runs there. Home closes, exactly as `enter` closes it. `/manual` is on this road since 2026-09-22: it is a question put to the model, so it needs a conversation to be asked in. | +| **`opens a conversation here first`** | `/files` · `/folder` `/place` `/dir` · `/manual` · `/crew` (bare) · `/permissions` `/perms` · `/connect` · `/harness` · `/subharness` · `/select` · `/rewind` `/undo` `/back` · `/compact` · `/export` `/save` · `/standing ` · `/task ` | Opens a conversation at the target — the folder and model on the rule above the box — then runs there. Home closes, exactly as `enter` closes it. `/manual` is on this road since 2026-09-22: it is a question put to the model, so it needs a conversation to be asked in. `/folder` joined it the same day — it gives THIS conversation a folder, and home has no this; the pin it used to be here is `/project`. | | **`answers here`** | `/help` · `/status` · `/cost` · `/cache` · `/budget` · `/crew ` · `/debug` · `/stop` · `/remember` · `/forget` · a word nobody defined | Answers with a note, and the first line of that note is put on home's own line under the box. `there is no command called /pricing · / lists them` is now something you can read. | | **`runs on the conversation behind home`** | `/land` · `/land ` · `/workspace ` | Acts on the conversation this window is holding behind the screen — not on the one `enter` would open — and its answer is echoed onto home's line. | | **`a fresh conversation behind home`** | `/new` `/clear` `/clean` `/reset` | Replaces the conversation behind the screen and says `started a fresh conversation behind home`. It is not the same act as `enter`, which opens a conversation at the target. | @@ -1280,14 +1280,14 @@ message. `/attach ~/shots/shot.png` is the same road for a picture. **A drop does the same thing without a command.** Drag a file onto the window while home is up and it lands on the same tray. So does a paste. -**A bare `/attach` opens the browser** (since 2026-09-22) — the same sheet a bare `/folder` -opens, aimed at the folder the next conversation opens in; a file chosen there lands on -home's tray. Choosing `/attach` on the `/` list with `enter` opens it at once; the +**A bare `/attach` opens the browser** (since 2026-09-22) — the same sheet a bare +`/project` opens, aimed at the folder the next conversation opens in; a file chosen there +lands on home's tray. Choosing `/attach` on the `/` list with `enter` opens it at once; the `/attach ` row under it is for a typed path. (It used to answer `type the path after /attach · or drop the file here`.) **A folder after `/attach` is not a file.** `/attach ~/src/parser` on home pins the next -conversation's folder — the same decision `/folder` makes — and updates the project +conversation's folder — the same decision `/project` makes — and updates the project path at the right end of the keys row. **The tray belongs to you, not to a conversation.** It survives walking into a conversation @@ -1341,7 +1341,7 @@ selected project. A new window starts with its own default. the keys row, and the target walks through the projects in the panel's order, including projects with only standing work, and wraps after the last. Both controls share one selection, shown only -as `project: ` at the right of the keys row. If `/folder` selected a destination outside the panel, +as `project: ` at the right of the keys row. If `/project` selected a destination outside the panel, the next cycle starts at its first project. With just one destination already selected, `alt+p project` is absent. @@ -1363,12 +1363,14 @@ standing choice. The selected project remains pinned. Neither cell is drawn on a Other full-screen places have no general conversation message box; return Home with Escape to start a conversation. -**`/folder` is the third door onto the same pin, and it is the one that shows you the disk.** -Typed on home — bare, or with a path after it — it opens the folder browser with the title -`the next conversation's folder`. Its action row reads `open the next conversation in · -~/src/parser`, and `enter` there pins the target and drops you back on home with the rule -already changed. `/place` and `/dir` are the same command. Nothing on that sheet touches the -conversation behind home. +**`/project` is the third door onto the same pin, and it is the one that shows you the disk.** +Typed bare on home it opens the folder browser with the title `the next conversation's +folder`. Its action row reads `open the next conversation in · ~/src/parser`, and `enter` +there pins the target and drops you back on home with the rule already changed. Nothing on +that sheet touches the conversation behind home. `/project ~/src/parser` skips the browser +and pins the folder straight away, saying `project · ~/src/parser`; a path that is not a +folder is refused as `no folder there · ` and nothing changes. This was `/folder` on +home until 2026-09-22, when the pin became a command of its own. ## How do I get back to the dashboard or the home screen from any page — Escape @@ -1389,8 +1391,9 @@ key or a command you have not used yet, and what it does — `/ask answers right opening a conversation`, `alt+1 to alt+7 jump straight to a place`. It is drawn only while the box is empty and nothing else is up, it moves on to the next tip every time you come to home and every two minutes at rest, and each tip goes away for good the first time you do -what it names. It sits at the right, led by a bulb and closed by a small cross a click puts -it away with until your next visit. A conversation has the same row over its own box, from +what it names. It sits at the right, led by a bulb and closed by a small cross: clicking it +means NOT THIS ONE, and the row answers with the next tip in the rotation rather than going +blank. The tip you put away keeps its whole allowance and comes round again. A conversation has the same row over its own box, from the same one list of tips, drawn once you have been quiet there for a minute. The keys row at the very foot is not a tip and never changes. The whole list, what makes each one appear and disappear, and the **disable hints** row on the Workspace tab that turns them off, are on diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index cf41ace05a..bc2d217f68 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -1504,7 +1504,7 @@ an email address, a Go doc link — never opens the list. **What it walks:** the conversation's workspace, or **your own machine's** working directory over `--host`. **On home** the same list opens over home's box (since 2026-09-22) and walks the folder the next conversation opens in — the one on the rule — -so moving the target with `alt+p` or `/folder` walks again; it offers files and folders +so moving the target with `alt+p` or `/project` walks again; it offers files and folders there and never tasks, because a task pointer is minted when a conversation sends and home has none yet. Skipped: `.git`, `vendor`, `node_modules`, every dot-directory, every dot-file, and every symlink. Unreadable directories are skipped diff --git a/internal/manual/chat/screen.md b/internal/manual/chat/screen.md index d3224a7ba3..8e60b947b1 100644 --- a/internal/manual/chat/screen.md +++ b/internal/manual/chat/screen.md @@ -72,8 +72,8 @@ the same layout and retain the full model identifier, including the organization **The project is not on either seam since 2026-09-22**: `project: ` is at the right end of the **keys row under the box**, on home and in a conversation alike — home's names where the next conversation opens, a conversation's names its own workspace — and both are -doors onto the folder chooser (a click, or `alt+p` on home and `/folder` in a -conversation). Model names on the seam and project paths on the keys row underline on +doors onto changing it: on home a click or `alt+p` walks the projects this machine knows +and `/project` opens the folder chooser, in a conversation a click or `/folder` opens it. Model names on the seam and project paths on the keys row underline on mouse-over; the model stays bold and bright. Paths truncate on the right, and the project goes entirely where the keys leave less than a word of room. The model stays bold and bright cyan on home and in conversations, and the effort has no badge. diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index 0a2f192c5f..1ff9c3443e 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -537,6 +537,12 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { // all until they said so. {"where did my changes go", "choosing-a-folder"}, {"merge what you did into my folder", "choosing-a-folder"}, + // /project split off from /folder on 2026-09-22: the person setting one, + // the person who typed /folder on home the old way, and the person who + // tried /project in a conversation. + {"how do I set the project on home", "choosing-a-folder"}, + {"which folder will my next conversation open in", "choosing-a-folder"}, + {"what happened to /folder on the home screen", "choosing-a-folder"}, {"put the changes into the folder", "choosing-a-folder"}, {"you changed my files?", "choosing-a-folder"}, {"undo what you did to my folder", "choosing-a-folder"}, @@ -550,6 +556,9 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { {"how do I copy text out of the conversation", "keys"}, {"is there a copy mode", "keys"}, {"what happened to /copy", "commands"}, + // The cross on a tip row, asked by somebody who pressed it and watched + // the row answer with a different sentence. + {"what does the x on the hint row do", "hints-and-tips"}, // The spell-it-out gesture, asked the three ways people meet it: wanting // it, seeing the hint and not knowing what it is, and being unhappy about // what came back. diff --git a/internal/tui3/app.go b/internal/tui3/app.go index 19a282e6ee..3d452d248b 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -7011,6 +7011,15 @@ func (a *app) slash(line string) tea.Cmd { // where it starts. return a.openFolderPick(rest) + case "project": + // AND THE OTHER HALF OF THE WORD IS HOME'S (projectcmd.go). A pin about + // the NEXT conversation means nothing inside one, and the two acts are + // one keystroke apart in spelling — so this says which screen it lives + // on and which command does the neighbouring job here, rather than + // quietly doing the neighbouring job. + a.note(projectIsHomesWord) + return nil + case "land": // The other end of choosing a folder: what was written for a folder this // conversation only refers to, put into it. Shown first and done second @@ -7029,6 +7038,7 @@ func (a *app) slash(line string) tea.Cmd { // answer: somebody who typed the word without the path is somebody who // does not know the path, and a browser is the thing they asked for. if strings.TrimSpace(rest) == "" { + a.noticeEvent(eventAttached) return a.openContextPick("", false) } a.attachFilePath(rest) diff --git a/internal/tui3/commands.go b/internal/tui3/commands.go index 015acf7d92..df219bc513 100644 --- a/internal/tui3/commands.go +++ b/internal/tui3/commands.go @@ -107,6 +107,13 @@ var commands = []command{ // three should have to find out which one this build chose. {name: "folder", desc: "choose a folder to work in · type a path to browse", alias: []string{"place", "dir"}}, {name: "folder", args: "", desc: "…open it already pointed at that path"}, + // AND THE OTHER HALF OF THE WORD, SPLIT OFF ON 2026-09-22. /folder gives + // THIS conversation a folder; this sets the one the next conversation + // opens in, and home is the only screen that has a next conversation + // (projectcmd.go). They sit together because a person who types either + // one meant one of the two and reads both rows on the way past. + {name: "project", desc: "the folder your next conversation opens in · on home"}, + {name: "project", args: "", desc: "…that folder, without opening the browser"}, // AND ITS OTHER END. Choosing a folder is where work aimed somewhere else // starts; this is where it arrives. It sits directly under /folder because // nobody reaches for it who has not already done the first — and because diff --git a/internal/tui3/contextmodal_test.go b/internal/tui3/contextmodal_test.go index a5d07df3b4..0e9fbacb64 100644 --- a/internal/tui3/contextmodal_test.go +++ b/internal/tui3/contextmodal_test.go @@ -371,7 +371,7 @@ func TestClosingTheSheetAsksForTheWholeScreenBack(t *testing.T) { t.Run("home esc", func(t *testing.T) { a, _, _ := mixedLab(t) runCmd(a.openHome()) - settleFolder(t, a, a.homeSlash("/folder")) + settleFolder(t, a, a.homeSlash("/project")) cmd := a.folderKey(tea.KeyPressMsg{Code: tea.KeyEscape}) if a.folder.open || !a.at(pageHome) { t.Fatalf("home esc left open=%v page=%v", a.folder.open, a.page) @@ -384,7 +384,7 @@ func TestClosingTheSheetAsksForTheWholeScreenBack(t *testing.T) { t.Run("home choice", func(t *testing.T) { a, _, _ := mixedLab(t) runCmd(a.openHome()) - settleFolder(t, a, a.homeSlash("/folder")) + settleFolder(t, a, a.homeSlash("/project")) onFolderRow(t, a, "inner") cmd := a.folderConfirm() if a.folder.open || !a.at(pageHome) { diff --git a/internal/tui3/foldercontext_test.go b/internal/tui3/foldercontext_test.go index cc2916abe6..f18e5f4509 100644 --- a/internal/tui3/foldercontext_test.go +++ b/internal/tui3/foldercontext_test.go @@ -928,10 +928,10 @@ func TestThePreviewDoorAndTheWayOutAreOnTheSheetAtEveryWidth(t *testing.T) { // Home has its own composer, so this is a real way to browse without replacing // the conversation's unsent draft with a slash command first. func TestContextBrowserFromHomeRevealsTheSheetAndPreservesTheChatDraft(t *testing.T) { - a, _, root := mixedLab(t) + a, _, _ := mixedLab(t) a.input.setText("keep this unsent draft") a.openHome() - settleFolder(t, a, a.homeSlash("/folder "+filepath.Join(root, "here")+"/")) + settleFolder(t, a, a.homeSlash("/project")) if a.at(pageHome) || !a.folder.open { t.Fatal("Home hides the context browser it just opened") } diff --git a/internal/tui3/folderpick.go b/internal/tui3/folderpick.go index ee485c63c8..87a2f01c46 100644 --- a/internal/tui3/folderpick.go +++ b/internal/tui3/folderpick.go @@ -204,7 +204,7 @@ type folderPick struct { // A FOLDER IS NEVER `held` ON THIS SHEET. `held` means "the conversation is // already about this", and the conversation this sheet is about does not // exist yet — so every folder on it is one that can be chosen, and the row - // never offers to remove one ([app.openTargetFolderPick] leaves the map + // never offers to remove one ([app.openTargetContextPick] leaves the map // empty for exactly that reason). forTarget bool diff --git a/internal/tui3/folderplace.go b/internal/tui3/folderplace.go index 3aa9d6ba6d..91ddb0ff42 100644 --- a/internal/tui3/folderplace.go +++ b/internal/tui3/folderplace.go @@ -209,20 +209,24 @@ const folderRemoteWord = "choosing a folder is not available over --host yet — // intent. See [app.openContextPick] for what the intent does and does not // decide. func (a *app) openFolderPick(query string) tea.Cmd { + a.noticeEvent(eventFolderPicked) return a.openContextPick(query, true) } -// openTargetFolderPick is /folder, /place and /dir TYPED AT HOME: the same one -// browser, opened about the conversation home is about to start rather than -// about the one this window is holding behind the screen. +// openTargetContextPick is THE SHEET HOME OPENS, with either intent: a bare +// /attach wants a FILE for the tray and a bare /project wants the folder the +// next conversation opens in, and both are one sheet whose confirm already +// does both (folderact.go's [app.targetFolderConfirm]). The intent decides +// only which tip the gesture retires (notice.go). // -// IT IS THE SAME SHEET AND NOT A SECOND ONE. Home's own answer to "which -// folder" used to be one line under the box — `alt+p moves the next conversation -// · or type a path` — which named a chord and a gesture and drew nothing a -// person could walk. The owner's word for it was that they did not notice it. -// So the command opens the browser every other surface opens, with three -// differences that all come from the same fact — the conversation this is about -// does not exist yet (folderpick.go's [folderPick.forTarget]): +// IT IS THE SAME SHEET AS THE CONVERSATION'S AND NOT A SECOND ONE. Home's own +// answer to "which folder" used to be one line under the box — `alt+p moves +// the next conversation · or type a path` — which named a chord and a gesture +// and drew nothing a person could walk. The owner's word for it was that they +// did not notice it. So the command opens the browser every other surface +// opens, with three differences that all come from the same fact — the +// conversation this is about does not exist yet (folderpick.go's +// [folderPick.forTarget]): // // - NO FOLDER DOOR IS REQUIRED. A pin is a string on this window; nothing is // referred to any agent, so a session that cannot hold a folder is no reason @@ -238,20 +242,15 @@ func (a *app) openFolderPick(query string) tea.Cmd { // read are the laptop's and the work is on the other machine, which is // [folderRemoteWord]'s argument said about the target: the pin would name a // directory the next conversation cannot open. -func (a *app) openTargetFolderPick(query string) tea.Cmd { - return a.openTargetContextPick(query, false) -} - -// openTargetContextPick is the sheet home opens, with either intent: a bare -// /attach wants a FILE for the tray and a bare /folder wants the next -// conversation's folder, and both are one sheet whose confirm already does -// both (folderact.go's [app.targetFolderConfirm]). The intent decides only -// which tip the gesture retires (notice.go). +// +// THE FOLDER DOOR ONTO IT IS /project SINCE 2026-09-22 (projectcmd.go), and +// not /folder: on home /folder opens a conversation and gives that one a +// folder, like every other command about a conversation. func (a *app) openTargetContextPick(query string, files bool) tea.Cmd { if files { a.noticeEvent(eventAttached) } else { - a.noticeEvent(eventFolderPicked) + a.noticeEvent(eventProjectSet) } if a.hosted() { a.home.say(folderRemoteWord, "") @@ -324,12 +323,14 @@ func (a *app) closeFolderSheet() tea.Cmd { // needs no folder door whatever. The sheet opens; a folder row on it then // refuses with the same sentence when it is confirmed (folderact.go). func (a *app) openContextPick(query string, folders bool) tea.Cmd { - // Either door found is a door learned, whatever the list answers (notice.go). - if folders { - a.noticeEvent(eventFolderPicked) - } else { - a.noticeEvent(eventAttached) - } + // THE DOOR FIRES ITS OWN EVENT AND THIS SHEET FIRES NONE (notice.go). One + // surface has three doors onto it — /folder, a bare /attach and /project — + // and a door found is a door learned, whatever the list then answers. It + // used to be decided HERE, off the `folders` flag, which is the flag that + // says whether a folder DOOR IS REQUIRED rather than which command was + // typed: home's sheet passes false for that reason, so opening it to pick + // a project retired the two /attach tips about a tray nothing went onto. + // // THE INTENT CHOOSES THE REFUSAL BEFORE THE LIST IS BUILT. The connection's // sentence used to be said for BOTH doors, which answered a request about a // file with an answer about folders and left the person who did not know the diff --git a/internal/tui3/home.go b/internal/tui3/home.go index 83d455acf5..2966f7e4ce 100644 --- a/internal/tui3/home.go +++ b/internal/tui3/home.go @@ -3854,8 +3854,9 @@ func (a *app) homePress(x, y int) tea.Cmd { if a.homePhone() { return a.homePhonePress(x, y) } - // THE CROSS ON THE TIP ROW PUTS THE TIP AWAY (hometip.go). It is read - // first because its row carries no other door and moves no cursor. + // THE CROSS ON THE TIP ROW MOVES THE ROW ON (hometip.go, notice.go's + // [app.noticeDismiss]). It is read first because its row carries no other + // door and moves no cursor. if a.tipRow >= 0 && y == a.tipRow && a.tipCloseSpan.holds(x) { a.noticeDismiss(slotHome) return nil diff --git a/internal/tui3/homefate_test.go b/internal/tui3/homefate_test.go index 592b336b32..89d8d9a349 100644 --- a/internal/tui3/homefate_test.go +++ b/internal/tui3/homefate_test.go @@ -74,8 +74,13 @@ func TestTheFateReadsTheArgumentWhereItChangesTheAnswer(t *testing.T) { {"task", "port the parser", fateNeedsChat}, {"memory", "", fatePlace}, {"memory", "branches", fateAnswers}, - {"folder", "", fateTargetFolder}, - {"folder", "~/src", fateTargetFolder}, + // /folder MEANS ONE THING EVERYWHERE since 2026-09-22: give THIS + // conversation a folder, so on home it needs one opened first. The pin + // it used to be here is /project (projectcmd.go). + {"folder", "", fateNeedsChat}, + {"folder", "~/src", fateNeedsChat}, + {"project", "", fateTargetFolder}, + {"project", "~/src", fateTargetFolder}, {"attach", "", fateTray}, {"attach", "shot.png", fateTray}, {"pricing", "", ""}, @@ -86,19 +91,19 @@ func TestTheFateReadsTheArgumentWhereItChangesTheAnswer(t *testing.T) { } } -// ── /folder: the browser, aimed at the target ─────────────────────────────── +// ── /project: the pin, and the browser behind it ──────────────────────────── -// BARE /folder OPENS THE ONE BROWSER, AIMED AT THE TARGET, and a folder +// BARE /project OPENS THE ONE BROWSER, AIMED AT THE TARGET, and a folder // confirmed there PINS THE NEXT CONVERSATION'S FOLDER rather than moving the // conversation behind home. Home comes back under it with the rule already // saying the new folder, which is the whole of what the owner asked to see. -func TestFolderAtHomeBrowsesForTheTargetAndPinsIt(t *testing.T) { +func TestProjectAtHomeBrowsesForTheTargetAndPinsIt(t *testing.T) { a, _, root := mixedLab(t) runCmd(a.openHome()) - settleFolder(t, a, a.homeSlash("/folder")) + settleFolder(t, a, a.homeSlash("/project")) if !a.folder.open || !a.folder.forTarget { - t.Fatalf("bare /folder did not open the browser for the target: open=%v target=%v", + t.Fatalf("bare /project did not open the browser for the target: open=%v target=%v", a.folder.open, a.folder.forTarget) } // The action row says what enter would do, in the target's own words. @@ -136,19 +141,105 @@ func TestFolderAtHomeBrowsesForTheTargetAndPinsIt(t *testing.T) { } } -// /folder WITH A PATH IS THE SAME SHEET, opened on that path — one question, one -// surface, whichever way it was asked. -func TestFolderWithAPathAtHomeIsTheSameTargetSheet(t *testing.T) { +// /project WITH A PATH TAKES THE PATH AND OPENS NOTHING. A person who typed the +// folder has already answered the question the browser exists to ask, and the +// pin is a string on this window rather than a round trip — so the keys row +// says the new folder on the very next frame. +func TestProjectWithAPathAtHomePinsItWithoutTheBrowser(t *testing.T) { a, _, root := mixedLab(t) runCmd(a.openHome()) - settleFolder(t, a, a.homeSlash("/folder "+filepath.Join(root, "here")+"/")) - if !a.folder.open || !a.folder.forTarget { - t.Fatalf("/folder at home did not open the target's browser: open=%v target=%v", - a.folder.open, a.folder.forTarget) + inner := filepath.Join(root, "here", "inner") + runCmd(a.homeSlash("/project " + inner)) + + if a.folder.open { + t.Fatal("/project opened the browser instead of taking the path") + } + if a.target.where != inner { + t.Fatalf("/project pinned %q, want %q", a.target.where, inner) + } + if !a.at(pageHome) { + t.Fatal("/project left home") + } + if want := projectSetWord + tildePath(inner, a.tilde); a.home.msg != want { + t.Fatalf("home said %q, want %q", a.home.msg, want) + } + if a.home.msgPath != inner { + t.Fatalf("the sentence hangs its door on %q, want %q", a.home.msgPath, inner) + } + if text := ansi.Strip(a.homeFootLine(400, a.pal)); !strings.Contains(text, targetPathWord(a)) { + t.Fatalf("the keys row does not name the folder that was just pinned:\n%s", text) + } +} + +// AND A PATH THAT IS NOT A FOLDER IS REFUSED IN THE WORDS THAT WERE TYPED. +// Nothing is pinned: a destination that is not there would be found out one +// `enter` later, in the conversation that could not open. +func TestProjectRefusesAPathThatIsNotAFolder(t *testing.T) { + a, _, root := mixedLab(t) + runCmd(a.openHome()) + + for _, rest := range []string{ + filepath.Join(root, "here", "notes.md"), + filepath.Join(root, "nowhere-at-all"), + } { + runCmd(a.homeSlash("/project " + rest)) + if a.target.where != "" { + t.Fatalf("/project %s pinned %q", rest, a.target.where) + } + if want := projectNoFolderWord + rest; a.home.msg != want { + t.Fatalf("home said %q, want %q", a.home.msg, want) + } + if a.folder.open { + t.Fatalf("/project %s opened the browser", rest) + } + } +} + +// /project IS HOME'S, AND A CONVERSATION SAYS SO. It used to be the home half +// of /folder, which is one keystroke apart in spelling from the command that +// does the neighbouring job here — so the answer names both. +func TestProjectInAConversationSaysItIsHomes(t *testing.T) { + a := newTestApp(&fakeAgent{}) + runCmd(a.slash("/project ~/src")) + + if a.target.where != "" { + t.Fatalf("/project in a conversation pinned %q", a.target.where) + } + if a.folder.open { + t.Fatal("/project in a conversation opened the browser") } - if a.folder.cols.dir != filepath.Join(root, "here") { - t.Fatalf("the sheet opened on %q, want the path that was typed", a.folder.cols.dir) + if text := transcriptText(a); !strings.Contains(text, projectIsHomesWord) { + t.Fatalf("the conversation does not say where /project lives:\n%s", text) + } +} + +// AND /folder ON HOME OPENS A CONVERSATION FIRST. It means one thing +// everywhere now — give THIS conversation a folder — and home has no this. +func TestFolderAtHomeOpensAConversationAndBrowsesThere(t *testing.T) { + lab := newHomeLab(t) + a := lab.door("") + a.key(key("esc")) + if !a.at(pageHome) { + t.Fatal("esc did not open home") + } + if got := homeFate("folder", ""); got != fateNeedsChat { + t.Fatalf("the drop-up says /folder %q on home", got) + } + + settleFolder(t, a, a.homeSlash("/folder")) + + if a.at(pageHome) { + t.Fatal("/folder at home stayed on home") + } + if !a.folder.open { + t.Fatal("/folder at home did not open the browser in the conversation it started") + } + if a.folder.forTarget { + t.Fatal("/folder at home opened the target's sheet, which is /project's") + } + if a.target.where != "" { + t.Fatalf("/folder at home pinned %q", a.target.where) } } @@ -160,7 +251,7 @@ func TestEscOutOfTheTargetBrowserLandsBackOnHome(t *testing.T) { runCmd(a.openHome()) was := a.targetWhere() - settleFolder(t, a, a.homeSlash("/folder")) + settleFolder(t, a, a.homeSlash("/project")) drive(t, a, key("esc")) if a.folder.open { diff --git a/internal/tui3/homeslash.go b/internal/tui3/homeslash.go index 57e9d9e576..8994f963d2 100644 --- a/internal/tui3/homeslash.go +++ b/internal/tui3/homeslash.go @@ -186,7 +186,9 @@ func homeFate(word, rest string) string { return fateAnswers case "model": return fateTargetModel - case "folder": + case "project": + // /project IS THE PIN AND /folder IS NOT, since 2026-09-22 + // (projectcmd.go says what the two used to share). return fateTargetFolder case "settings", "search", "spend", "history", "home": return fatePlace @@ -201,11 +203,16 @@ func homeFate(word, rest string) string { case "land", "workspace": return fateBehind case "files", "permissions", "connect", "harness", "subharness", "autonomy", - "select", "rewind", "compact", "export", "drafts", "manual": + "select", "rewind", "compact", "export", "drafts", "manual", "folder": // /manual IS HERE SINCE 2026-09-22 and not among the answers: it is a // turn of a conversation now (manualcmd.go), and a turn needs one. As // an answer it printed the pages into the conversation BEHIND home, // where the person who typed it could see nothing happen. + // + // AND /folder JOINED IT THE SAME DAY. It means one thing everywhere + // now — give THIS conversation a folder — so on home it needs one, + // exactly like /files. The pin it used to be here is /project + // (projectcmd.go). return fateNeedsChat case "standing": // Bare it is the standing place; with words it is a card raised in a @@ -290,13 +297,12 @@ func (a *app) homeSlash(line string) tea.Cmd { return a.homeModelCommand(rest) case fateTargetFolder: - // THE BROWSER, AIMED AT THE TARGET (folderplace.go). Bare it opens where - // the next conversation would; with a path it opens on that path. Both - // forms answer one question — which folder does the next conversation open - // in — so both open the one surface that answers it, and picking a row - // pins the rule above home's box rather than moving the conversation - // behind the screen. - return a.openTargetFolderPick(rest) + // /project (projectcmd.go). With a path it takes that path; bare it is + // the browser, opened where the next conversation would open. Both + // forms answer one question — which folder does the next conversation + // open in — and either way the rule above home's box changes rather + // than the conversation behind the screen. + return a.runProjectCommand(rest) case fateResume: h.say(homeIsTheResumeWord, "") diff --git a/internal/tui3/homeslash_test.go b/internal/tui3/homeslash_test.go index 0c367ac1b9..5f43ea96a2 100644 --- a/internal/tui3/homeslash_test.go +++ b/internal/tui3/homeslash_test.go @@ -546,14 +546,17 @@ func TestResumeAnswersOnHomesOwnLineAndFolderOpensTheBrowser(t *testing.T) { t.Fatalf("/resume said %q, want %q", a.home.msg, homeIsTheResumeWord) } - // /folder is the other half of this test's original claim and it moved: it - // used to answer in one line — `alt+p moves the next conversation · or type - // a path` — which named two gestures and drew neither. It opens the browser - // now, aimed at the target (folderplace.go), and the browser takes the frame. - typeHome(a, "/folder") + // /project is the other half of this test's original claim and it moved + // twice: home's answer to "which folder" used to be one line — `alt+p moves + // the next conversation · or type a path` — which named two gestures and + // drew neither; then it was a bare /folder, which meant one thing here and + // another in a conversation. It is /project since 2026-09-22 + // (projectcmd.go), it opens the browser aimed at the target, and the + // browser takes the frame. + typeHome(a, "/project") runCmd(a.key(key("enter"))) if !a.folder.open { - t.Fatal("/folder at home did not open the folder browser") + t.Fatal("/project at home did not open the folder browser") } if !a.folder.forTarget { t.Fatal("the browser home opened is not aimed at the target") diff --git a/internal/tui3/hometip_test.go b/internal/tui3/hometip_test.go index 0fea9cb8fd..c19eb46a59 100644 --- a/internal/tui3/hometip_test.go +++ b/internal/tui3/hometip_test.go @@ -247,9 +247,10 @@ func TestEveryTipIsOnTheManualPage(t *testing.T) { } } -// The cut was thirty, and there is ONE set: every hint draws on both boxes, -// a news row on neither, and a row filed under home's slot does not build. -func TestTheTableIsThirtyHintsAndEveryOneDrawsOnBothBoxes(t *testing.T) { +// The cut was thirty and /project made it thirty-one, and there is ONE set: +// every hint draws on both boxes, a news row on neither, and a row filed under +// home's slot does not build. +func TestTheTableIsThirtyOneHintsAndEveryOneDrawsOnBothBoxes(t *testing.T) { hints := 0 for _, n := range notices { if n.slot != slotHint { @@ -263,8 +264,8 @@ func TestTheTableIsThirtyHintsAndEveryOneDrawsOnBothBoxes(t *testing.T) { t.Errorf("hint %q draws in the transcript", n.id) } } - if hints != 30 { - t.Fatalf("the table holds %d hints, want 30 — the cut is deliberate, and the manual page counts them", hints) + if hints != 31 { + t.Fatalf("the table holds %d hints, want 31 — the cut is deliberate, and the manual page counts them", hints) } news := notice{id: "noted", slot: slotNote, armed: ready, text: "x"} if news.draws(slotHint) || news.draws(slotHome) || !news.draws(slotNote) { @@ -274,3 +275,191 @@ func TestTheTableIsThirtyHintsAndEveryOneDrawsOnBothBoxes(t *testing.T) { t.Fatal("a row filed under home's slot was accepted") } } + +// ── the cross ─────────────────────────────────────────────────────────────── + +// THE CROSS MEANS "NOT THIS ONE, SAY SOMETHING ELSE": the row answers with the +// next tip in the rotation, and the tip put away is charged NOTHING — however +// long it had been standing when the cross was pressed. It used to be charged +// a showing and the row went blank, so six presses retired a tip nobody had +// read and the next visit to home brought the same sentence straight back. +func TestTheCrossMovesTheRowOnAndSpendsNothingOfTheTipItPutAway(t *testing.T) { + lab := newHomeLab(t) + a := lab.door("") + now := time.Date(2026, 9, 22, 12, 0, 0, 0, time.UTC) + a.clock = func() time.Time { return now } + a.showPage(pageHome) + + was := a.notices.current[slotHome] + if was == "" { + t.Fatal("home opened with no tip") + } + // LONG ENOUGH TO HAVE BEEN READ, which is the case that used to cost a + // showing: the gesture says the opposite of "I have read this". + now = now.Add(noticeReadTime * 3) + a.noticeDismiss(slotHome) + + if got := a.notices.current[slotHome]; got == was { + t.Fatalf("the cross left %q standing", got) + } + if a.noticeHomeHint() == "" { + t.Fatal("the cross left the row blank with other tips still to say") + } + if got := a.notices.ledger.shown(was); got != 0 { + t.Fatalf("the cross spent %d showings of the tip it put away", got) + } + if a.notices.retired(was) { + t.Fatalf("the cross retired %q", was) + } + + // AND IT COMES ROUND AGAIN. The ring is a ring: walk it and the tip that + // was put away takes its turn like every other row. + seen := false + for i := 0; i <= len(notices); i++ { + a.noticeHomeRotate() + if a.notices.current[slotHome] == was { + seen = true + break + } + } + if !seen { + t.Fatalf("%q never came back round after its cross was pressed", was) + } +} + +// AND WITH NOTHING ELSE TRUE TO SAY THE ROW GOES BLANK. Drawing the same +// sentence again under the cross somebody just pressed would be the surface +// arguing, so the one-eligible-tip case keeps the old behaviour: hidden until +// the slot next changes hands. +func TestTheCrossBlanksTheRowWhenItIsTheLastTipStanding(t *testing.T) { + lab := newHomeLab(t) + a := lab.door("") + a.showPage(pageHome) + + // Retire every tip but the one standing, so the ring is that one tip. + last := a.notices.current[slotHome] + if last == "" { + t.Fatal("home opened with no tip") + } + for _, n := range notices { + if n.id != last { + a.notices.retire(n.id) + } + } + a.noticeHomeRotate() + if got := a.notices.current[slotHome]; got != last { + t.Fatalf("the last tip standing is %q, want %q", got, last) + } + + a.noticeDismiss(slotHome) + if got := a.noticeHomeHint(); got != "" { + t.Fatalf("the cross redrew the only tip there was: %q", got) + } + if a.notices.retired(last) { + t.Fatal("the cross retired the last tip standing") + } +} + +// A TIP ABOUT A COMMAND ONLY HOME HAS IS ONLY ARMED ON HOME. One list feeds +// both boxes, so `/project sets the folder the next conversation opens in` +// over a conversation's box would be teaching a command that answers there by +// pointing back at home. +func TestTheProjectTipStandsOnHomeAndNowhereElse(t *testing.T) { + lab := newHomeLab(t) + a := lab.door("") + + a.showPage(pageHome) + if !onHome(a) { + t.Fatal("the home-only rule is not armed on home") + } + seen := false + for i := 0; i <= len(notices); i++ { + if a.notices.current[slotHome] == "pick-a-project" { + seen = true + break + } + a.noticeHomeRotate() + } + if !seen { + t.Fatal("the /project tip never came round on home") + } + + // AND IT STANDS DOWN THE MOMENT HOME IS NOT IN FRONT. The conversation's + // row is decided again at the next event, and the rule is false there. + runCmd(a.showPage(pageNone)) + if a.at(pageHome) { + t.Fatal("the conversation did not come back to the frame") + } + if onHome(a) { + t.Fatal("the home-only rule is armed in a conversation") + } + a.noticeEvent(eventTurnEnded) + for slot, id := range a.notices.current { + if id == "pick-a-project" { + t.Fatalf("the /project tip is standing in slot %d off home", slot) + } + } +} + +// AND TAKING A PROJECT RETIRES IT, by either form of the command. +func TestTakingAProjectRetiresItsTip(t *testing.T) { + for _, take := range []struct { + name string + do func(*app, string) + }{ + {"a path after it", func(a *app, dir string) { runCmd(a.homeSlash("/project " + dir)) }}, + {"the browser it opens", func(a *app, _ string) { runCmd(a.homeSlash("/project")) }}, + } { + t.Run(take.name, func(t *testing.T) { + lab := newHomeLab(t) + a := lab.door("") + a.showPage(pageHome) + if a.notices.retired("pick-a-project") { + t.Fatal("the tip was retired before the command ran") + } + take.do(a, t.TempDir()) + if !a.notices.retired("pick-a-project") { + t.Fatal("taking a project did not retire its tip") + } + }) + } +} + +// A TIP BEHIND A BLANK ROW IS NOT BEING SHOWN. Once the cross has hidden a +// row — the one-eligible-tip case — the events that go on re-deciding the slot +// may not start a standing for a sentence nobody can read, or the tip left +// there would spend its six showings on a row that draws nothing. +func TestATipBehindAHiddenRowStandsForNothing(t *testing.T) { + lab := newHomeLab(t) + a := lab.door("") + now := time.Date(2026, 9, 22, 12, 0, 0, 0, time.UTC) + a.clock = func() time.Time { return now } + a.showPage(pageHome) + + last := a.notices.current[slotHome] + if last == "" { + t.Fatal("home opened with no tip") + } + for _, n := range notices { + if n.id != last { + a.notices.retire(n.id) + } + } + a.noticeHomeRotate() + a.noticeDismiss(slotHome) + if a.noticeHomeHint() != "" { + t.Fatal("the cross did not blank the row") + } + + // AN HOUR OF EVENTS OVER A BLANK ROW. Each one re-decides the slot. + for i := 0; i < noticeShownDefault*3; i++ { + now = now.Add(noticeReadTime * 2) + a.noticeEvent(eventTurnEnded) + } + if got := a.notices.ledger.shown(last); got != 0 { + t.Fatalf("a tip behind a blank row was shown %d times", got) + } + if a.notices.retired(last) { + t.Fatal("a tip behind a blank row retired itself") + } +} diff --git a/internal/tui3/hometiplayout_test.go b/internal/tui3/hometiplayout_test.go index 1485e4d527..254ecd6a74 100644 --- a/internal/tui3/hometiplayout_test.go +++ b/internal/tui3/hometiplayout_test.go @@ -24,9 +24,9 @@ func homeFrameLines(a *app) []string { } // The tip is right-aligned over the rule, led by the bulb and closed by the -// cross, ending one cell in from the edge — and the cross puts it away until -// the next visit. -func TestHomeTipIsRightAlignedWithABulbAndACrossThatPutsItAway(t *testing.T) { +// cross, ending one cell in from the edge — and the cross moves the row on to +// another tip. +func TestHomeTipIsRightAlignedWithABulbAndACrossThatMovesTheRowOn(t *testing.T) { lab := newHomeLab(t) a := lab.door("") a.showPage(pageHome) @@ -54,7 +54,8 @@ func TestHomeTipIsRightAlignedWithABulbAndACrossThatPutsItAway(t *testing.T) { if a.targetRow != a.tipRow+1 { t.Fatalf("the tip is on row %d and the rule on row %d; they should be neighbours", a.tipRow, a.targetRow) } - // THE CROSS. A press on it puts the tip away; a press beside it does not. + // THE CROSS. A press on it says NOT THIS ONE, and the row answers with + // another tip on the very next frame; a press beside it does nothing. if !a.tipCloseSpan.pressable() { t.Fatal("the draw recorded no columns for the cross") } @@ -62,17 +63,27 @@ func TestHomeTipIsRightAlignedWithABulbAndACrossThatPutsItAway(t *testing.T) { if a.noticeHomeHint() != tip { t.Fatal("a press on the tip's words put it away") } + was := a.notices.current[slotHome] a.homePress(a.tipCloseSpan.from, a.tipRow) - if got := a.noticeHomeHint(); got != "" { - t.Fatalf("the cross did not put the tip away: %q", got) + next := a.noticeHomeHint() + if next == "" { + t.Fatal("the cross left the row blank with other tips still to say") + } + if next == tip { + t.Fatalf("the cross left the same tip standing: %q", next) } if strings.Contains(homeText(a), tip) { t.Fatal("the tip is still drawn after its cross was pressed") } - if a.notices.retired(a.notices.current[slotHome]) { + // AND THE ONE PUT AWAY KEEPS ITS WHOLE ALLOWANCE: it was not retired, and + // no showing was spent on the gesture. + if a.notices.retired(was) { t.Fatal("putting a tip away retired it") } - // The next visit brings a tip back. + if got := a.notices.ledger.shown(was); got != 0 { + t.Fatalf("the cross spent %d showings of the tip it put away", got) + } + // The next visit still has something to say. a.showPage(pageHome) if a.noticeHomeHint() == "" { t.Fatal("the next visit brought no tip back") diff --git a/internal/tui3/notice.go b/internal/tui3/notice.go index 5a1067d74f..08e8701b49 100644 --- a/internal/tui3/notice.go +++ b/internal/tui3/notice.go @@ -153,9 +153,13 @@ const ( // eventAttached is a file or picture put on the tray by path, or the // browser opened to choose one (attach.go, folderplace.go). eventAttached = "attached" - // eventFolderPicked is the folder chooser raised, from a conversation or - // aimed at home's target (folderplace.go). + // eventFolderPicked is the folder chooser raised from a conversation — + // /folder itself, or the project word on the keys row (folderplace.go, + // projectseam.go). eventFolderPicked = "folder-picked" + // eventProjectSet is /project on home reaching a folder: a path after it + // taken, or the browser it opens bare (projectcmd.go). + eventProjectSet = "project-set" // eventModelListOpened is the model list raised, over a conversation or // over home's draft (palette.go, homedraft.go). eventModelListOpened = "model-list-opened" @@ -196,7 +200,7 @@ var noticeEvents = []string{ eventCompacted, eventFilesOpened, eventResumeOpened, eventCostShown, eventStandingOpened, eventDeliverableMade, eventAsked, eventTaskTyped, eventManualAsked, eventTabReopened, eventAtOpened, eventAttached, - eventFolderPicked, eventModelListOpened, eventCrewShown, eventBudgetShown, + eventFolderPicked, eventProjectSet, eventModelListOpened, eventCrewShown, eventBudgetShown, eventSpendOpened, eventSteered, eventQueued, eventChatStarted, eventPlaceJumped, eventRemembered, eventSearchOpened, eventSubharnessOpened, eventConnectOpened, eventAutonomyAsked, @@ -297,6 +301,10 @@ var ( // may not hand the surface (homeexchange.go's [app.askHereWith]) — and the // person standing on home, where the sentence it arms is true. askable = func(a *app) bool { return a.errand != nil && a.at(pageHome) } + // onHome is a tip about a command home is the only screen for: it is armed + // while home is in front and stands down the moment it is not, so the one + // list can hold a sentence that would be a lie over a conversation's box. + onHome = func(a *app) bool { return a.at(pageHome) } ) // notices is the table, and ITS ORDER IS THE ORDER THE ROWS COME ROUND IN on @@ -305,9 +313,10 @@ var ( // and the page say the same words — and notice_test.go holds the page to every // line here, so the table cannot say a thing the manual does not. // -// THIRTY ROWS, AND THE CUT WAS DELIBERATE. A survey of the surface on +// THIRTY-ONE ROWS, AND THE CUT WAS DELIBERATE. A survey of the surface on // 2026-09-21 turned up forty-eight lines worth saying; these are the thirty -// that teach a door a person cannot see from the box. What was left out is +// that teach a door a person cannot see from the box, and /project made +// thirty-one when it became a command of its own on 2026-09-22. What was left out is // what the foot already names — `alt+p`, `alt+e`, `alt+a`, `alt+k`, `/` — and // the second spelling of anything already here. `/ shows every command` was a // row until both feet started saying `/ commands` outright (footswap.go). @@ -411,6 +420,15 @@ var notices = []notice{ text: "/folder picks the folder codeaf works in", retire: eventFolderPicked, }, + { + // ON HOME ALONE, because /project is home's alone (projectcmd.go). A + // conversation's box would be reading it over a command that answers + // there by pointing back at home. + id: "pick-a-project", slot: slotHint, + armed: onHome, + text: "/project sets the folder the next conversation opens in", + retire: eventProjectSet, + }, { id: "attach-a-picture", slot: slotHint, armed: ready, @@ -635,8 +653,10 @@ type noticeBoard struct { // counted from it when the tip leaves or the row goes out of sight // ([noticeBoard.settle]), and only if it stood [noticeReadTime]. since [noticeSlots]time.Time - // hidden is the cross on a row having been pressed: the tip standing is - // not drawn until the slot next changes hands, which clears it. It is this + // hidden is the cross on a row having been pressed WITH NOTHING ELSE TO + // PUT THERE: the tip standing is not drawn until the slot next changes + // hands, which clears it. Ordinarily the cross rotates instead + // ([app.noticeDismiss]), so this is the one-eligible-tip case. It is this // session's and never the ledger's — putting a tip away is not using it. hidden [noticeSlots]bool // touched is the last proof the person was doing something in a @@ -903,19 +923,22 @@ func (a *app) noticeFill(slot noticeSlot) bool { b.armed[slot][n.id] = armed } id := b.pick(slot, cands) - live, now := a.noticeLive(slot), a.now() - changed, wrote := b.take(slot, id, live, now, a.noticeLimit) - // A ROW IN FRONT WITH A TIP ON IT IS BEING SHOWN, whether the tip was - // decided just now or before the row came into view. - if live { - b.visible(slot, now) - } + now := a.now() + changed, wrote := b.take(slot, id, a.noticeLive(slot), now, a.noticeLimit) if changed { // A new tip is a new thing to read: the clock starts again and a cross // pressed over the old one is spent. - b.at[slot] = a.now() + b.at[slot] = now b.hidden[slot] = false } + // A ROW IN FRONT WITH A TIP ON IT IS BEING SHOWN, whether the tip was + // decided just now or before the row came into view. IT IS ASKED AGAIN + // HERE, after the cross above was spent: a tip arriving on a hidden row + // starts no standing, and the same decision that un-hides the row is what + // starts one. + if a.noticeLive(slot) { + b.visible(slot, now) + } if changed && id != "" { a.noticeShow(slot, id) } @@ -925,7 +948,15 @@ func (a *app) noticeFill(slot noticeSlot) bool { // noticeLive is whether a slot's row can be seen at all right now — which is // what makes a change of hands a showing ([noticeBoard.take]): home's row // while home is in front, the conversation's once its quiet minute has passed. +// +// A ROW WHOSE CROSS HAS BEEN PRESSED IS NOT LIVE. It draws nothing until the +// slot next changes hands ([noticeBoard.hidden]), and a tip standing behind a +// blank row is a tip nobody is reading — which is the whole of what +// [noticeReadTime] exists to tell apart. func (a *app) noticeLive(slot noticeSlot) bool { + if a.notices.hidden[slot] { + return false + } switch slot { case slotHome: return a.at(pageHome) @@ -1024,14 +1055,37 @@ func (a *app) noticeHomeQuiet() bool { a.paneExchange() == nil && !a.targetPickShowing() && !a.composer.open && !a.hopShowing() } -// noticeDismiss is the cross on a tip row: the tip goes away until the row -// next changes hands — the next visit to home, the next turn of its clock — -// and nothing is written down, because a tip put away is not a tip learned. +// noticeDismiss is the cross on a tip row, and what it means is NOT THIS ONE, +// SAY SOMETHING ELSE — so the row moves on to the next tip in the rotation on +// the very next frame rather than going blank. +// +// AND THE TIP THAT WAS PUT AWAY KEEPS ITS WHOLE ALLOWANCE. Its standing is +// thrown away rather than counted: a person who pressed the cross was telling +// the surface they did not want to read that line now, which is the opposite +// of having read it, and spending a showing on the gesture would retire a tip +// six dismissals in. It goes back into the ring and comes round another time. +// +// Until 2026-09-22 the cross counted the standing and left the row BLANK until +// the slot next changed hands, which on home meant the same tip was back on +// the next visit with one of its six showings gone. The owner met exactly +// that with the /attach tip. +// +// THE ROW ONLY GOES BLANK WHEN THERE IS NOTHING ELSE TO SAY. With one tip left +// in the ring the rotation is that tip, and drawing it again under the cross +// somebody just pressed would be the surface arguing — so the row is hidden +// the way it always was, until the slot next changes hands. func (a *app) noticeDismiss(slot noticeSlot) { - // A tip put away has been seen, for as long as it stood. - a.noticeSettle(slot) - a.notices.hidden[slot] = true - a.touch() + b := &a.notices + if b.seen == nil { + *b = bareNoticeBoard() + } + b.since[slot] = time.Time{} + held := b.current[slot] + a.noticeRotate(slot) + if b.current[slot] == held { + b.hidden[slot] = true + a.touch() + } } // noticeShow puts a newly chosen notice where its slot draws. The hint slots diff --git a/internal/tui3/notice_test.go b/internal/tui3/notice_test.go index 713b165ba9..96d5558a0b 100644 --- a/internal/tui3/notice_test.go +++ b/internal/tui3/notice_test.go @@ -661,8 +661,15 @@ func TestEveryRetireEventIsProvedByItsGesture(t *testing.T) { t.Fatal("typing @ did not open the completion") } }, - eventAttached: func(t *testing.T, a *app) { a.slash("/attach") }, - eventFolderPicked: func(t *testing.T, a *app) { a.slash("/folder") }, + eventAttached: func(t *testing.T, a *app) { a.slash("/attach") }, + eventFolderPicked: func(t *testing.T, a *app) { a.slash("/folder") }, + // /project IS HOME'S ALONE (projectcmd.go), so its gesture is made + // there — and with a real directory after it, which is the form that + // takes a folder without opening anything. + eventProjectSet: func(t *testing.T, a *app) { + runCmd(a.showPage(pageHome)) + runCmd(a.homeSlash("/project " + t.TempDir())) + }, eventModelListOpened: func(t *testing.T, a *app) { a.slash("/model") }, eventCrewShown: func(t *testing.T, a *app) { a.slash("/crew") }, eventBudgetShown: func(t *testing.T, a *app) { a.slash("/budget") }, diff --git a/internal/tui3/projectcmd.go b/internal/tui3/projectcmd.go new file mode 100644 index 0000000000..0416af16f4 --- /dev/null +++ b/internal/tui3/projectcmd.go @@ -0,0 +1,94 @@ +package tui3 + +// /project — WHICH FOLDER THE NEXT CONVERSATION OPENS IN, AND NOTHING ELSE. +// +// Until 2026-09-22 this was the home half of `/folder`, and the owner's word +// for that was that `/folder` was doing two different jobs: in a conversation +// it hands the conversation a directory to be about, the way `/attach` hands +// it a file; on home the same word pinned the project the NEXT conversation +// would open in. Two acts behind one command, told apart by which screen you +// happened to be standing on. +// +// So the pin is its own command now. `/folder` means one thing everywhere — +// give this conversation a folder — and typing it on home opens a conversation +// first, like every other command about a conversation (homeslash.go's +// [fateNeedsChat]). `/project` is the pin, it lives on home, and a conversation +// answers it by saying so rather than by doing something else. +// +// ITS TWO FORMS ANSWER THE SAME QUESTION FROM THE TWO ENDS. With a path after +// it the answer is already known and the command takes it; bare, the person is +// asking to be shown the disk, and the browser opens the way it always did +// ([app.openProjectPick]). + +import ( + "os" + "strings" + + tea "charm.land/bubbletea/v2" +) + +// The sentences /project says. Each is quoted in the manual exactly as it is +// spelled here. +const ( + // projectSetWord leads the line a taken path writes on home. The path is + // the whole of what a person cannot see anywhere else at that moment, so + // it is the ink and the label stays dim — which is how `folder ·`, + // `workspace ·` and `model ·` all say their answers (folderplace.go's + // [app.referPlace] states that law). + projectSetWord = "project · " + // projectNoFolderWord is a path that is not a directory on this machine: a + // typo, a file, or somewhere that has been moved since. It names what was + // typed rather than what it resolved to, because the resolved form is not + // what the person can see to correct. + projectNoFolderWord = "no folder there · " + // projectIsHomesWord is /project typed in a conversation. It says which + // screen the command lives on AND which command does the neighbouring job + // here, because somebody who typed it in a conversation wanted one of the + // two and both answers are one line. + projectIsHomesWord = "/project is home's · it sets the folder the next conversation opens in · /folder gives this conversation one" +) + +// runProjectCommand is /project on home. See this file's header. +// +// NOTHING HERE TOUCHES A DISK EXCEPT THE ONE STAT ON THE PATH THAT WAS TYPED, +// and that stat is the whole point of the command: a pin naming a folder that +// is not there would be a destination the next conversation cannot open, found +// out one `enter` later. One stat of one named directory is what [app.attach] +// already pays on the same road, and it is not a walk. +func (a *app) runProjectCommand(rest string) tea.Cmd { + rest = strings.TrimSpace(rest) + if rest == "" { + return a.openProjectPick("") + } + // OVER A CONNECTION THERE IS NOTHING TRUE TO PIN. The folders this process + // can stat are the laptop's and the next conversation is on the other + // machine, which is [folderRemoteWord]'s argument said about the pin + // rather than about the browser. + if a.hosted() { + a.home.say(folderRemoteWord, "") + return nil + } + path := a.resolvePath(rest) + info, err := os.Stat(path) + if path == "" || err != nil || !info.IsDir() { + a.home.say(projectNoFolderWord+rest, "") + a.touch() + return nil + } + a.noticeEvent(eventProjectSet) + // A PIN IS A STRING ON THIS WINDOW AND NEVER A ROUND TRIP (folderact.go's + // [app.targetFolderConfirm] says why that matters): the keys row under the + // box says the new folder on the very next frame. + a.target.where = path + a.home.say(projectSetWord+tildePath(path, a.tilde), path) + a.touch() + return nil +} + +// openProjectPick is a bare /project: the ONE context browser, opened about the +// conversation home is about to start rather than about the one this window is +// holding behind the screen (folderplace.go's [app.openTargetContextPick] has +// the three things that makes different). +func (a *app) openProjectPick(query string) tea.Cmd { + return a.openTargetContextPick(query, false) +} diff --git a/internal/tui3/projectseam.go b/internal/tui3/projectseam.go index 3ecc698fab..742be96f63 100644 --- a/internal/tui3/projectseam.go +++ b/internal/tui3/projectseam.go @@ -61,9 +61,10 @@ func (a *app) seamProjectPress(x, y int) (tea.Cmd, bool) { } // tipClosePress is a press on the cross at the end of the conversation's tip -// row (view.go's [chromeTip]): the tip goes away until the row next changes -// hands (notice.go's [app.noticeDismiss]). It reports whether it took the -// press; the rest of that row is blank, and blank is not a gesture. +// row (view.go's [chromeTip]): the row moves on to the next tip, and the one +// put away keeps its whole allowance (notice.go's [app.noticeDismiss]). It +// reports whether it took the press; the rest of that row is blank, and blank +// is not a gesture. func (a *app) tipClosePress(x, y int) bool { mark, ok := a.chromeAt(y) if !ok || mark.kind != chromeTip || !a.tipCloseSpan.holds(x) { From 38c6c0804be587080f19b3bf905a9e6ba3f0cdc6 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Tue, 22 Sep 2026 16:51:50 -0400 Subject: [PATCH 10/39] chat: the cross holds the row blank until the row leaves the frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner's ruling: "do not show another hint until the user comes back to the home tab after leaving it". The cross answered with a second sentence on the very screen somebody had just asked it to stop talking on, which is the surface arguing. A crossed row now draws nothing at all, and ONE thing lifts it: the row going out of view. On home that is leaving home — the cross is lifted in dropHome, so it holds for the whole of the visit it was pressed on, and coming back brings a different tip. In a conversation it is the next key taking the row away and the next quiet minute returning it. Neither the two-minute beat nor an event re-deciding the slot lifts it any more; those were the two roads a tip came back on. The tip put away still keeps its whole allowance: the standing is thrown away rather than counted, and the slot is asked to advance so the row that comes back is a different one. Co-Authored-By: Claude Opus 5 --- internal/manual/chat/hints-and-tips.md | 22 +++--- internal/manual/chat/home.md | 5 +- internal/tui3/chattip_test.go | 24 +++++-- internal/tui3/home.go | 6 +- internal/tui3/hometip_test.go | 96 +++++++++++++++----------- internal/tui3/hometiplayout_test.go | 32 +++++---- internal/tui3/notice.go | 69 +++++++++--------- 7 files changed, 150 insertions(+), 104 deletions(-) diff --git a/internal/manual/chat/hints-and-tips.md b/internal/manual/chat/hints-and-tips.md index 394b380886..93bc478a47 100644 --- a/internal/manual/chat/hints-and-tips.md +++ b/internal/manual/chat/hints-and-tips.md @@ -16,13 +16,16 @@ again. Left alone, the row moves on to the next tip every two minutes. On home t there from the first minute, moves on every time you come to home and every two minutes at rest, and goes blank while the box is being typed into or a list is up. -**The small cross after the tip means NOT THIS ONE**: click it and the row moves on to the -next tip in the rotation, on the very next frame. The tip you put away is **not** spent — -it keeps its whole allowance, nothing is written down, and it comes round again another -time. Only when there is nothing else true to say does the row go blank instead, until it -next changes hands. Until 2026-09-22 the cross always blanked the row AND counted the tip -as shown, so the same tip was back on the next visit with one of its six showings gone. -A tip never takes a row of its own: it stands on the blank row that separates the conversation (or home's list) from the +**The small cross after the tip means ENOUGH OF THESE FOR NOW**: click it and the row goes +blank and stays blank — no second sentence takes its place on the screen you are still +looking at. **The cross is lifted when the row leaves the frame, and by nothing else.** On +home that means leaving home and coming back; in a conversation it means the next key +taking the row away and the next quiet minute bringing it back. Neither the two-minute +beat nor anything else happening on the screen brings it back sooner. The tip you put away +is **not** spent: it keeps its whole allowance, nothing is written down, and the row that +comes back is a different one, with the one you dismissed taking its turn again later. +Until 2026-09-22 the cross counted the tip as shown, so the same sentence was back with +one of its six showings gone. A tip never takes a row of its own: it stands on the blank row that separates the conversation (or home's list) from the rule, and never blocks a keystroke. The keys row at the very foot — `alt+e effort · alt+a approvals · / commands` — is not a tip and never changes; until 2026-09-22 the tip stood there in a conversation, and it moved up to the row over the rule so both boxes say their @@ -40,8 +43,9 @@ list or a reply being read all take the row back — and it moves on to the next true for you on every road home (`esc` from a conversation, `/home`, `alt+1`, `tab`), in a fixed order, round and round. Left at rest, it moves on by itself after two minutes; a home nobody is looking at (the box being typed into, a list up) does not age, because what has -not been read has not been shown. The cross at its end moves the row on to another tip and -costs the one you put away nothing. +not been read has not been shown. The cross at its end blanks the row for the rest of that +visit — leaving home and coming back is what brings the next tip — and costs the one you +put away nothing. ## Why did the hint disappear — each tip retires once you use what it teaches diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index 4801fc1a70..3b276afd9d 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -1392,8 +1392,9 @@ opening a conversation`, `alt+1 to alt+7 jump straight to a place`. It is drawn the box is empty and nothing else is up, it moves on to the next tip every time you come to home and every two minutes at rest, and each tip goes away for good the first time you do what it names. It sits at the right, led by a bulb and closed by a small cross: clicking it -means NOT THIS ONE, and the row answers with the next tip in the rotation rather than going -blank. The tip you put away keeps its whole allowance and comes round again. A conversation has the same row over its own box, from +means ENOUGH FOR NOW, and the row stays blank until you leave home and come back, when a +different tip is there. The tip you put away keeps its whole allowance and comes round +again. A conversation has the same row over its own box, from the same one list of tips, drawn once you have been quiet there for a minute. The keys row at the very foot is not a tip and never changes. The whole list, what makes each one appear and disappear, and the **disable hints** row on the Workspace tab that turns them off, are on diff --git a/internal/tui3/chattip_test.go b/internal/tui3/chattip_test.go index 090db9f8e3..0f6f94f2ce 100644 --- a/internal/tui3/chattip_test.go +++ b/internal/tui3/chattip_test.go @@ -120,16 +120,32 @@ func TestAConversationSaysATipOnlyAfterAQuietMinute(t *testing.T) { if b.retired("task-page-after-first-task") { t.Fatal("putting a tip away retired it") } - // THE NEXT BEAT BRINGS A TIP BACK — the ring has one eligible tip here, so - // it is the same one. + // AND THE BEAT ALONE DOES NOT BRING IT BACK (the owner's ruling, + // 2026-09-22): a cross holds the row until the row leaves the frame, and + // the two-minute beat is what used to answer it with another sentence on + // the screen somebody was still sitting in front of. advance(hintEvery) if cmd := a.noticeIdleBeat(b.idleGen); cmd == nil { t.Fatal("the beat after a quiet minute did not re-arm") } + if got := a.noticeHint(); got != "" { + t.Fatalf("the beat brought the row back under a cross: %q", got) + } + // A KEY TAKES THE ROW, and the next quiet minute gives it back — the ring + // has one eligible tip here, so it is the same one. + drive(t, a, key("y"), key("backspace")) + if b.due || a.noticeHint() != "" { + t.Fatalf("a key did not stand the tip down: due=%v hint=%q", b.due, a.noticeHint()) + } + advance(chatHintIdle) + if cmd := a.noticeIdleBeat(b.idleGen); cmd == nil { + t.Fatal("the beat after the fresh quiet minute did not re-arm") + } if got := a.noticeHint(); got != taskPageTip { - t.Fatalf("the beat did not bring the tip back: %q", got) + t.Fatalf("a key and a fresh quiet minute did not bring the row back: %q", got) } - // AND A KEY HIDES IT until the next quiet minute. + // And a key stands it down again, which is where the rest of this test + // picks up. drive(t, a, key("y"), key("backspace")) if b.due || a.noticeHint() != "" { t.Fatalf("a key did not stand the tip down: due=%v hint=%q", b.due, a.noticeHint()) diff --git a/internal/tui3/home.go b/internal/tui3/home.go index 2966f7e4ce..6ec7149d54 100644 --- a/internal/tui3/home.go +++ b/internal/tui3/home.go @@ -1194,8 +1194,12 @@ func (a *app) closeHome() { func (a *app) dropHome() { a.homeGen++ // THE TIP ON HOME'S ROW GOES OUT OF SIGHT HERE, so here is where its - // standing is measured (notice.go's [app.noticeSettle]). + // standing is measured (notice.go's [app.noticeSettle]) — AND HERE IS + // WHERE A CROSS PRESSED ON IT IS LIFTED. The row a person put away stays + // away for the whole of the visit they pressed it on; coming back to home + // is what brings the next tip ([noticeBoard.hidden]). a.noticeSettle(slotHome) + a.notices.hidden[slotHome] = false // CLOSING IS THE LOOK. The stamp the next open measures news against is // written here and only here — see [homeView.seen] for why not on the way // in, and session's look.go for why a window that dies instead loses diff --git a/internal/tui3/hometip_test.go b/internal/tui3/hometip_test.go index c19eb46a59..5f44a800b0 100644 --- a/internal/tui3/hometip_test.go +++ b/internal/tui3/hometip_test.go @@ -278,32 +278,28 @@ func TestTheTableIsThirtyOneHintsAndEveryOneDrawsOnBothBoxes(t *testing.T) { // ── the cross ─────────────────────────────────────────────────────────────── -// THE CROSS MEANS "NOT THIS ONE, SAY SOMETHING ELSE": the row answers with the -// next tip in the rotation, and the tip put away is charged NOTHING — however -// long it had been standing when the cross was pressed. It used to be charged -// a showing and the row went blank, so six presses retired a tip nobody had -// read and the next visit to home brought the same sentence straight back. -func TestTheCrossMovesTheRowOnAndSpendsNothingOfTheTipItPutAway(t *testing.T) { +// THE CROSS MEANS "ENOUGH OF THESE FOR NOW": the row goes blank and no second +// sentence takes its place on the screen the person is still standing on. The +// tip put away is charged NOTHING — however long it had been standing when the +// cross was pressed, because the gesture says the opposite of "I have read +// this" (it used to be charged a showing, so six presses retired a tip nobody +// had read). +func TestTheCrossBlanksHomesRowAndSpendsNothingOfTheTipItPutAway(t *testing.T) { lab := newHomeLab(t) a := lab.door("") now := time.Date(2026, 9, 22, 12, 0, 0, 0, time.UTC) a.clock = func() time.Time { return now } - a.showPage(pageHome) + runCmd(a.showPage(pageHome)) was := a.notices.current[slotHome] if was == "" { t.Fatal("home opened with no tip") } - // LONG ENOUGH TO HAVE BEEN READ, which is the case that used to cost a - // showing: the gesture says the opposite of "I have read this". now = now.Add(noticeReadTime * 3) a.noticeDismiss(slotHome) - if got := a.notices.current[slotHome]; got == was { - t.Fatalf("the cross left %q standing", got) - } - if a.noticeHomeHint() == "" { - t.Fatal("the cross left the row blank with other tips still to say") + if got := a.noticeHomeHint(); got != "" { + t.Fatalf("the cross answered with another tip: %q", got) } if got := a.notices.ledger.shown(was); got != 0 { t.Fatalf("the cross spent %d showings of the tip it put away", got) @@ -312,6 +308,27 @@ func TestTheCrossMovesTheRowOnAndSpendsNothingOfTheTipItPutAway(t *testing.T) { t.Fatalf("the cross retired %q", was) } + // AND NOTHING THAT HAPPENS ON HOME BRINGS ONE BACK. The two-minute beat is + // the one that used to, and an event re-deciding the slot is the other. + now = now.Add(hintEvery * 3) + a.noticeHomeBeat() + a.noticeEvent(eventTurnEnded) + if got := a.noticeHomeHint(); got != "" { + t.Fatalf("the row came back on the same visit: %q", got) + } + + // LEAVING HOME AND COMING BACK IS WHAT LIFTS IT, and it lifts to a + // different tip. + runCmd(a.showPage(pageNone)) + runCmd(a.showPage(pageHome)) + back := a.noticeHomeHint() + if back == "" { + t.Fatal("coming back to home brought no tip") + } + if a.notices.current[slotHome] == was { + t.Fatalf("coming back brought the tip the cross put away: %q", was) + } + // AND IT COMES ROUND AGAIN. The ring is a ring: walk it and the tip that // was put away takes its turn like every other row. seen := false @@ -327,36 +344,35 @@ func TestTheCrossMovesTheRowOnAndSpendsNothingOfTheTipItPutAway(t *testing.T) { } } -// AND WITH NOTHING ELSE TRUE TO SAY THE ROW GOES BLANK. Drawing the same -// sentence again under the cross somebody just pressed would be the surface -// arguing, so the one-eligible-tip case keeps the old behaviour: hidden until -// the slot next changes hands. -func TestTheCrossBlanksTheRowWhenItIsTheLastTipStanding(t *testing.T) { - lab := newHomeLab(t) - a := lab.door("") - a.showPage(pageHome) - - // Retire every tip but the one standing, so the ring is that one tip. - last := a.notices.current[slotHome] - if last == "" { - t.Fatal("home opened with no tip") +// AND THE SAME LAW IN A CONVERSATION, where the row going out of view is a key +// rather than a door: the cross blanks it, the quiet minutes that follow do +// not bring it back, and a key and a fresh quiet minute do. +func TestTheCrossHoldsTheConversationsRowUntilAKeyAndAFreshQuietMinute(t *testing.T) { + a, _ := sheetApp(t) + a.turn = 1 + a.noticeEvent(eventTurnEnded) + advance := quietMinute(a) + if a.noticeHint() == "" { + t.Fatal("the quiet minute drew no tip") } - for _, n := range notices { - if n.id != last { - a.notices.retire(n.id) - } + + a.noticeDismiss(slotHint) + if got := a.noticeHint(); got != "" { + t.Fatalf("the cross answered with another tip: %q", got) } - a.noticeHomeRotate() - if got := a.notices.current[slotHome]; got != last { - t.Fatalf("the last tip standing is %q, want %q", got, last) + // The beat that turns the ring every two minutes does not lift it. + advance(hintEvery * 2) + a.noticeIdleBeat(a.notices.idleGen) + if got := a.noticeHint(); got != "" { + t.Fatalf("the two-minute beat brought the row back: %q", got) } - a.noticeDismiss(slotHome) - if got := a.noticeHomeHint(); got != "" { - t.Fatalf("the cross redrew the only tip there was: %q", got) - } - if a.notices.retired(last) { - t.Fatal("the cross retired the last tip standing") + // A key takes the row, and the next quiet minute gives it back. + a.noticeTouched() + advance(chatHintIdle) + a.noticeIdleBeat(a.notices.idleGen) + if a.noticeHint() == "" { + t.Fatal("a key and a fresh quiet minute did not bring the row back") } } diff --git a/internal/tui3/hometiplayout_test.go b/internal/tui3/hometiplayout_test.go index 254ecd6a74..1f3d1ec1a1 100644 --- a/internal/tui3/hometiplayout_test.go +++ b/internal/tui3/hometiplayout_test.go @@ -24,9 +24,9 @@ func homeFrameLines(a *app) []string { } // The tip is right-aligned over the rule, led by the bulb and closed by the -// cross, ending one cell in from the edge — and the cross moves the row on to -// another tip. -func TestHomeTipIsRightAlignedWithABulbAndACrossThatMovesTheRowOn(t *testing.T) { +// cross, ending one cell in from the edge — and the cross blanks the row for +// the rest of this visit to home. +func TestHomeTipIsRightAlignedWithABulbAndACrossThatBlanksTheRow(t *testing.T) { lab := newHomeLab(t) a := lab.door("") a.showPage(pageHome) @@ -54,8 +54,8 @@ func TestHomeTipIsRightAlignedWithABulbAndACrossThatMovesTheRowOn(t *testing.T) if a.targetRow != a.tipRow+1 { t.Fatalf("the tip is on row %d and the rule on row %d; they should be neighbours", a.tipRow, a.targetRow) } - // THE CROSS. A press on it says NOT THIS ONE, and the row answers with - // another tip on the very next frame; a press beside it does nothing. + // THE CROSS. A press on it says ENOUGH FOR NOW and the row goes blank; a + // press beside it does nothing. if !a.tipCloseSpan.pressable() { t.Fatal("the draw recorded no columns for the cross") } @@ -65,12 +65,8 @@ func TestHomeTipIsRightAlignedWithABulbAndACrossThatMovesTheRowOn(t *testing.T) } was := a.notices.current[slotHome] a.homePress(a.tipCloseSpan.from, a.tipRow) - next := a.noticeHomeHint() - if next == "" { - t.Fatal("the cross left the row blank with other tips still to say") - } - if next == tip { - t.Fatalf("the cross left the same tip standing: %q", next) + if got := a.noticeHomeHint(); got != "" { + t.Fatalf("the cross answered with another tip: %q", got) } if strings.Contains(homeText(a), tip) { t.Fatal("the tip is still drawn after its cross was pressed") @@ -83,10 +79,16 @@ func TestHomeTipIsRightAlignedWithABulbAndACrossThatMovesTheRowOn(t *testing.T) if got := a.notices.ledger.shown(was); got != 0 { t.Fatalf("the cross spent %d showings of the tip it put away", got) } - // The next visit still has something to say. - a.showPage(pageHome) - if a.noticeHomeHint() == "" { - t.Fatal("the next visit brought no tip back") + // LEAVING HOME AND COMING BACK IS WHAT BRINGS ONE, and it is a different + // one. + runCmd(a.showPage(pageNone)) + runCmd(a.showPage(pageHome)) + back := a.noticeHomeHint() + if back == "" { + t.Fatal("the next visit to home brought no tip back") + } + if back == tip { + t.Fatalf("the next visit brought back the tip the cross put away: %q", back) } } diff --git a/internal/tui3/notice.go b/internal/tui3/notice.go index 08e8701b49..b6b420ffba 100644 --- a/internal/tui3/notice.go +++ b/internal/tui3/notice.go @@ -653,11 +653,16 @@ type noticeBoard struct { // counted from it when the tip leaves or the row goes out of sight // ([noticeBoard.settle]), and only if it stood [noticeReadTime]. since [noticeSlots]time.Time - // hidden is the cross on a row having been pressed WITH NOTHING ELSE TO - // PUT THERE: the tip standing is not drawn until the slot next changes - // hands, which clears it. Ordinarily the cross rotates instead - // ([app.noticeDismiss]), so this is the one-eligible-tip case. It is this - // session's and never the ledger's — putting a tip away is not using it. + // hidden is the cross on a row having been pressed: the row draws nothing + // at all until THE ROW ITSELF GOES OUT OF VIEW, which is the only thing + // that lifts it — home closing ([app.dropHome]) for home's row, a key + // taking the conversation's row back and the next quiet minute returning + // it ([app.noticeIdleBeat]) for that one. Deciding the slot again does + // not lift it, and neither does the two-minute beat: a cross answered + // with another sentence on the same screen is the surface talking over + // somebody who asked it to stop (the owner's ruling, 2026-09-22). It is + // this session's and never the ledger's — putting a tip away is not + // using it. hidden [noticeSlots]bool // touched is the last proof the person was doing something in a // conversation — a key pressed, a turn ending — and due is whether they @@ -926,16 +931,14 @@ func (a *app) noticeFill(slot noticeSlot) bool { now := a.now() changed, wrote := b.take(slot, id, a.noticeLive(slot), now, a.noticeLimit) if changed { - // A new tip is a new thing to read: the clock starts again and a cross - // pressed over the old one is spent. + // A new tip is a new thing to read, so its clock starts again. THE + // CROSS IS NOT SPENT HERE: a row somebody put away stays away until + // that row leaves the frame ([noticeBoard.hidden]), and a tip arriving + // behind it is a tip nobody is being shown. b.at[slot] = now - b.hidden[slot] = false } // A ROW IN FRONT WITH A TIP ON IT IS BEING SHOWN, whether the tip was - // decided just now or before the row came into view. IT IS ASKED AGAIN - // HERE, after the cross above was spent: a tip arriving on a hidden row - // starts no standing, and the same decision that un-hides the row is what - // starts one. + // decided just now or before the row came into view. if a.noticeLive(slot) { b.visible(slot, now) } @@ -994,13 +997,17 @@ func (a *app) noticeLimit(id string) int { // rest ([app.noticeHomeBeat]), and on the conversation's beat while the person // stays quiet ([app.noticeIdleBeat]). Rotating is the one thing an event does // not do to a slot, so it is its own seam. +// +// IT DOES NOT LIFT A CROSS. The row a person put away is put away until it +// leaves the frame, and the beat that turns the ring every two minutes is +// exactly the thing that used to bring a tip back onto a home they were still +// standing on ([noticeBoard.hidden]). func (a *app) noticeRotate(slot noticeSlot) { b := &a.notices if b.seen == nil { *b = bareNoticeBoard() } b.advance[slot] = true - b.hidden[slot] = false if a.noticeFill(slot) { b.save() } @@ -1055,37 +1062,33 @@ func (a *app) noticeHomeQuiet() bool { a.paneExchange() == nil && !a.targetPickShowing() && !a.composer.open && !a.hopShowing() } -// noticeDismiss is the cross on a tip row, and what it means is NOT THIS ONE, -// SAY SOMETHING ELSE — so the row moves on to the next tip in the rotation on -// the very next frame rather than going blank. +// noticeDismiss is the cross on a tip row, and what it means is ENOUGH OF +// THESE FOR NOW — not "say something else". The row goes blank and STAYS +// blank for the rest of this sitting: on home, until home is left and come +// back to; in a conversation, until the row goes out of sight under a key and +// the next quiet minute brings it back ([noticeBoard.hidden] names both, and +// they are the same law — the cross is lifted by the row going out of view). +// +// THE OWNER'S RULING, 2026-09-22: "do not show another hint until the user +// comes back to the home tab after leaving it". A cross answered with a second +// sentence in the same breath is the surface talking over somebody who has +// just asked it to stop. // // AND THE TIP THAT WAS PUT AWAY KEEPS ITS WHOLE ALLOWANCE. Its standing is // thrown away rather than counted: a person who pressed the cross was telling // the surface they did not want to read that line now, which is the opposite // of having read it, and spending a showing on the gesture would retire a tip -// six dismissals in. It goes back into the ring and comes round another time. -// -// Until 2026-09-22 the cross counted the standing and left the row BLANK until -// the slot next changed hands, which on home meant the same tip was back on -// the next visit with one of its six showings gone. The owner met exactly -// that with the /attach tip. -// -// THE ROW ONLY GOES BLANK WHEN THERE IS NOTHING ELSE TO SAY. With one tip left -// in the ring the rotation is that tip, and drawing it again under the cross -// somebody just pressed would be the surface arguing — so the row is hidden -// the way it always was, until the slot next changes hands. +// six dismissals in. The slot is asked to advance, so the row that comes back +// is a different one and this tip takes its turn again later in the ring. func (a *app) noticeDismiss(slot noticeSlot) { b := &a.notices if b.seen == nil { *b = bareNoticeBoard() } b.since[slot] = time.Time{} - held := b.current[slot] - a.noticeRotate(slot) - if b.current[slot] == held { - b.hidden[slot] = true - a.touch() - } + b.hidden[slot] = true + b.advance[slot] = true + a.touch() } // noticeShow puts a newly chosen notice where its slot draws. The hint slots From 559fcf2ef322da633f37044a61bda44f80108bc8 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Tue, 22 Sep 2026 18:09:45 -0400 Subject: [PATCH 11/39] =?UTF-8?q?chat:=20the=20owner's=20read=20of=20the?= =?UTF-8?q?=20tip=20list=20=E2=80=94=20twenty-six=20rows,=20five=20respell?= =?UTF-8?q?ed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nine changes, all the owner's: - `@ completes a file, a folder or a task into your message` becomes `type @ to quickly attach files in the current project` - `/attach takes a picture too, or paste a screenshot in` becomes `/attach lets browse anywhere for files` - `/export writes this whole conversation to a file` becomes `/export writes the current conversation to a file` - `/budget caps what today may cost` becomes `/budget sets the spending cap for the day` - `/connect links Google, Slack or another model service` becomes `/connect links Notion, Slack and other services` - the steer row and the queue row become one, retired by the queue: `using enter stops and steers conversations, use ctrl+q to queue` - `alt+3`, `alt+1`–`alt+7`, `/search` and `/subharness` come off the list altogether — four doors the tab bar and the `/` list already put in front of somebody, which is the argument that kept `alt+p` and `/` off it in the first place Thirty-one rows become twenty-six. The five respelled rows KEEP their ids, because the gesture each teaches has not changed and a new id would say the tip again to everybody who has already retired it; the folded row takes a new id, `steer-and-queue`, because somebody who retired one of the pair has not been told the other half. The events behind the four cut rows stay: nothing retires on them now, which is already true of `task-started`, and they are what an arming rule reads. Co-Authored-By: Claude Opus 5 --- internal/manual/chat/hints-and-tips.md | 36 +++++++-------- internal/manual/chat/home.md | 2 +- internal/tui3/hometip_test.go | 13 +++--- internal/tui3/notice.go | 63 +++++++++----------------- 4 files changed, 47 insertions(+), 67 deletions(-) diff --git a/internal/manual/chat/hints-and-tips.md b/internal/manual/chat/hints-and-tips.md index 93bc478a47..55be59f2b7 100644 --- a/internal/manual/chat/hints-and-tips.md +++ b/internal/manual/chat/hints-and-tips.md @@ -6,7 +6,7 @@ The dim sentence directly above the rule over your message box, led by a bulb an by a small cross — `💡 ctrl+. sees every task this project has run ✕` — is a **tip**: one line naming a key or a command you have not used yet, and what it does. It reads the way every hint on this surface does: the key or the command first, then what it does. Home has -the same row over its own box, and the two rows draw from **one list** of thirty-one tips +the same row over its own box, and the two rows draw from **one list** of twenty-six tips (below). **In a conversation the row appears only once you have been quiet for a minute** — no key @@ -92,7 +92,7 @@ later when the ring comes round. It jumps once and then takes its turn like the ## Every hint codeaf can show, and what makes each one go away -There are thirty-one, one list for both boxes. Each one says the moment it first appears +There are twenty-six, one list for both boxes. Each one says the moment it first appears and the gesture that retires it. The list is the program's own table (the surface refuses to build if the two disagree), so a tip you saw is on it word for word. @@ -126,7 +126,7 @@ build if the two disagree), so a tip you saw is on it word for word. **Files and context** -- `@ completes a file, a folder or a task into your message` — retired when the `@` list +- `type @ to quickly attach files in the current project` — retired when the `@` list opens. - `/attach sends a file along with your message` — retired when a file goes on the tray by path or the file browser opens. @@ -135,9 +135,9 @@ build if the two disagree), so a tip you saw is on it word for word. after it or on the browser it opens. - `/folder picks the folder codeaf works in` — retired when the folder chooser opens, from a conversation or aimed at home's target. -- `/attach takes a picture too, or paste a screenshot in` — retired by the same gesture as +- `/attach lets browse anywhere for files` — retired by the same gesture as the other `/attach` tip. -- `/export writes this whole conversation to a file` — after two exchanges. Retired when +- `/export writes the current conversation to a file` — after two exchanges. Retired when an export lands. **Models, thinking and cost** @@ -146,37 +146,35 @@ build if the two disagree), so a tip you saw is on it word for word. opens, over a conversation or over home's draft. - `/crew sets the models codeaf uses on its own behalf` — retired when `/crew` answers, bare or with a preset. -- `/budget caps what today may cost` — retired when `/budget` answers. -- `alt+3 shows what this machine has spent, by the day` — retired when the spend place - opens by any door. +- `/budget sets the spending cap for the day` — retired when `/budget` answers. **Steering a running answer** -- `enter while an answer is coming stops it and steers` — after the first exchange. - Retired the first time you steer. -- `ctrl+q queues this message for after the current turn` — after the first exchange. - Retired the first time you queue one. +- `using enter stops and steers conversations, use ctrl+q to queue` — after the first + exchange. Retired the first time you queue a message. It was two rows until 2026-09-22 + — one for the steer and one for the queue — and the owner folded them into one. **Moving around** - `ctrl+t starts a fresh chat in this folder` — retired when the new-chat page opens. -- `alt+1 to alt+7 jump straight to a place` — retired the first time a place chord reaches - one. **Memory, accounts and the rest** - `/remember keeps one thing across conversations` — retired when `/remember` is typed. -- `/search finds anything ever said on this machine` — retired when the search place opens - by any door. -- `/subharness lists the programs you can run` — retired when `/subharness` is typed, bare - or with a name. -- `/connect links Google, Slack or another model service` — retired when the connect panel +- `/connect links Notion, Slack and other services` — retired when the connect panel is reached for. - `/autonomy sets how questions are handled while you are away` — after the first exchange. Retired when `/autonomy` is typed, bare or with a rule. (It took the seat `ctrl+b freezes the screen so you can read and copy from it` held for one build on 2026-09-22, and `ask for a picture, a voiceover, music or a video` before that.) +**Four rows came off on 2026-09-22**, on the owner's read of the whole list: `alt+3 shows +what this machine has spent, by the day`, `alt+1 to alt+7 jump straight to a place`, +`/search finds anything ever said on this machine` and `/subharness lists the programs you +can run`. All four name doors the tab bar or the `/` list already puts in front of you, +which is the same argument that kept `alt+p`, `alt+e` and `/` off the list in the first +place. The features are unchanged; only the tips about them are gone. + Unless a line above says otherwise, a tip is true from the first minute on home and after the first exchange in a conversation. diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index 3b276afd9d..632823e8a5 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -1388,7 +1388,7 @@ The double-space binding has been removed. Spaces type normally in message boxes The one dim line directly above the rule over home's box is a **tip**: one sentence naming a key or a command you have not used yet, and what it does — `/ask answers right here without -opening a conversation`, `alt+1 to alt+7 jump straight to a place`. It is drawn only while +opening a conversation`, `ctrl+t starts a fresh chat in this folder`. It is drawn only while the box is empty and nothing else is up, it moves on to the next tip every time you come to home and every two minutes at rest, and each tip goes away for good the first time you do what it names. It sits at the right, led by a bulb and closed by a small cross: clicking it diff --git a/internal/tui3/hometip_test.go b/internal/tui3/hometip_test.go index 5f44a800b0..7d14359826 100644 --- a/internal/tui3/hometip_test.go +++ b/internal/tui3/hometip_test.go @@ -247,10 +247,11 @@ func TestEveryTipIsOnTheManualPage(t *testing.T) { } } -// The cut was thirty and /project made it thirty-one, and there is ONE set: -// every hint draws on both boxes, a news row on neither, and a row filed under -// home's slot does not build. -func TestTheTableIsThirtyOneHintsAndEveryOneDrawsOnBothBoxes(t *testing.T) { +// The cut was thirty, /project made it thirty-one, and the owner's read of the +// whole list took it to twenty-six. There is ONE set: every hint draws on both +// boxes, a news row on neither, and a row filed under home's slot does not +// build. +func TestTheTableIsTwentySixHintsAndEveryOneDrawsOnBothBoxes(t *testing.T) { hints := 0 for _, n := range notices { if n.slot != slotHint { @@ -264,8 +265,8 @@ func TestTheTableIsThirtyOneHintsAndEveryOneDrawsOnBothBoxes(t *testing.T) { t.Errorf("hint %q draws in the transcript", n.id) } } - if hints != 31 { - t.Fatalf("the table holds %d hints, want 31 — the cut is deliberate, and the manual page counts them", hints) + if hints != 26 { + t.Fatalf("the table holds %d hints, want 26 — the cut is deliberate, and the manual page counts them", hints) } news := notice{id: "noted", slot: slotNote, armed: ready, text: "x"} if news.draws(slotHint) || news.draws(slotHome) || !news.draws(slotNote) { diff --git a/internal/tui3/notice.go b/internal/tui3/notice.go index b6b420ffba..3cf97100c5 100644 --- a/internal/tui3/notice.go +++ b/internal/tui3/notice.go @@ -313,10 +313,13 @@ var ( // and the page say the same words — and notice_test.go holds the page to every // line here, so the table cannot say a thing the manual does not. // -// THIRTY-ONE ROWS, AND THE CUT WAS DELIBERATE. A survey of the surface on -// 2026-09-21 turned up forty-eight lines worth saying; these are the thirty -// that teach a door a person cannot see from the box, and /project made -// thirty-one when it became a command of its own on 2026-09-22. What was left out is +// TWENTY-SIX ROWS, AND EVERY CUT WAS DELIBERATE. A survey of the surface on +// 2026-09-21 turned up forty-eight lines worth saying; thirty of those shipped, +// /project made thirty-one when it became a command of its own on 2026-09-22, +// and the owner's read of the whole list that same day took it to twenty-six: +// `alt+3`, `alt+1`–`alt+7`, `/search` and `/subharness` came off as rows the +// foot or the tab bar already teaches, and the two lines about a running +// answer became one. What was left out is // what the foot already names — `alt+p`, `alt+e`, `alt+a`, `alt+k`, `/` — and // the second spelling of anything already here. `/ shows every command` was a // row until both feet started saying `/ commands` outright (footswap.go). @@ -405,7 +408,7 @@ var notices = []notice{ { id: "at-completion", slot: slotHint, armed: ready, - text: "@ completes a file, a folder or a task into your message", + text: "type @ to quickly attach files in the current project", retire: eventAtOpened, }, { @@ -430,15 +433,18 @@ var notices = []notice{ retire: eventProjectSet, }, { + // THE ID OUTLIVED ITS OWN WORDS. The line named a picture until + // 2026-09-22 and names the browser now; the id may not change with it + // ([notice.id] says why), so it reads as a misnomer on purpose. id: "attach-a-picture", slot: slotHint, armed: ready, - text: "/attach takes a picture too, or paste a screenshot in", + text: "/attach lets browse anywhere for files", retire: eventAttached, }, { id: "export-the-conversation", slot: slotHint, armed: func(a *app) bool { return a.turn >= 2 }, - text: "/export writes this whole conversation to a file", + text: "/export writes the current conversation to a file", retire: eventDeliverableMade, }, // ── models, thinking and cost ─────────────────────────────────────────── @@ -457,26 +463,19 @@ var notices = []notice{ { id: "budget-cap", slot: slotHint, armed: ready, - text: "/budget caps what today may cost", + text: "/budget sets the spending cap for the day", retire: eventBudgetShown, }, - { - id: "spend-place", slot: slotHint, - armed: ready, - text: "alt+3 shows what this machine has spent, by the day", - retire: eventSpendOpened, - }, // ── steering a running answer ─────────────────────────────────────────── { - id: "steer-with-enter", slot: slotHint, - armed: spoken, - text: "enter while an answer is coming stops it and steers", - retire: eventSteered, - }, - { - id: "queue-with-ctrl-q", slot: slotHint, + // ONE LINE FOR THE TWO THINGS A KEY DOES OVER A RUNNING ANSWER, since + // 2026-09-22: `steer-with-enter` and `queue-with-ctrl-q` were a row + // each and the owner folded them together. It is a NEW id and not + // either of theirs, because a person who retired one of the pair has + // not been told the other half ([notice.id]). + id: "steer-and-queue", slot: slotHint, armed: spoken, - text: "ctrl+q queues this message for after the current turn", + text: "using enter stops and steers conversations, use ctrl+q to queue", retire: eventQueued, }, // ── moving around ─────────────────────────────────────────────────────── @@ -486,12 +485,6 @@ var notices = []notice{ text: "ctrl+t starts a fresh chat in this folder", retire: eventChatStarted, }, - { - id: "place-chords", slot: slotHint, - armed: ready, - text: "alt+1 to alt+7 jump straight to a place", - retire: eventPlaceJumped, - }, // ── memory, accounts and the rest ─────────────────────────────────────── { id: "remember-one-thing", slot: slotHint, @@ -499,22 +492,10 @@ var notices = []notice{ text: "/remember keeps one thing across conversations", retire: eventRemembered, }, - { - id: "search-place", slot: slotHint, - armed: ready, - text: "/search finds anything ever said on this machine", - retire: eventSearchOpened, - }, - { - id: "subharness-list", slot: slotHint, - armed: ready, - text: "/subharness lists the programs you can run", - retire: eventSubharnessOpened, - }, { id: "connect-accounts", slot: slotHint, armed: ready, - text: "/connect links Google, Slack or another model service", + text: "/connect links Notion, Slack and other services", retire: eventConnectOpened, }, { From 2331389be0d5d4e544b0477002cc970ec420cb82 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Tue, 22 Sep 2026 18:17:42 -0400 Subject: [PATCH 12/39] chat: the /attach browser tip reads "lets you browse" The line went in as `/attach lets browse anywhere for files` and was missing its "you". Same id, same gesture, one word. Co-Authored-By: Claude Opus 5 --- internal/manual/chat/hints-and-tips.md | 2 +- internal/tui3/notice.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/manual/chat/hints-and-tips.md b/internal/manual/chat/hints-and-tips.md index 55be59f2b7..315e7c987a 100644 --- a/internal/manual/chat/hints-and-tips.md +++ b/internal/manual/chat/hints-and-tips.md @@ -135,7 +135,7 @@ build if the two disagree), so a tip you saw is on it word for word. after it or on the browser it opens. - `/folder picks the folder codeaf works in` — retired when the folder chooser opens, from a conversation or aimed at home's target. -- `/attach lets browse anywhere for files` — retired by the same gesture as +- `/attach lets you browse anywhere for files` — retired by the same gesture as the other `/attach` tip. - `/export writes the current conversation to a file` — after two exchanges. Retired when an export lands. diff --git a/internal/tui3/notice.go b/internal/tui3/notice.go index 3cf97100c5..d90743054c 100644 --- a/internal/tui3/notice.go +++ b/internal/tui3/notice.go @@ -438,7 +438,7 @@ var notices = []notice{ // ([notice.id] says why), so it reads as a misnomer on purpose. id: "attach-a-picture", slot: slotHint, armed: ready, - text: "/attach lets browse anywhere for files", + text: "/attach lets you browse anywhere for files", retire: eventAttached, }, { From 6cb8a022b456a81f56da441ad595e376c9f646bb Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:01:54 -0400 Subject: [PATCH 13/39] Restore double-space Home navigation and ask-here choices --- internal/e2e/questions_e2e_test.go | 6 +- internal/e2e/stopbound_e2e_test.go | 16 +- internal/e2e/tui_e2e_test.go | 60 ++- internal/e2e/tuiwords_test.go | 12 +- internal/manual/chat/asking-from-home.md | 25 +- internal/manual/chat/commands.md | 47 +- .../manual/chat/compacting-over-and-over.md | 2 +- internal/manual/chat/empty-screen.md | 6 +- internal/manual/chat/hints-and-tips.md | 10 +- internal/manual/chat/home.md | 234 +++++--- internal/manual/chat/keys.md | 194 ++++--- internal/manual/chat/models-and-cost.md | 2 +- internal/manual/chat/places.md | 19 +- internal/manual/chat/screen.md | 62 ++- internal/manual/chat/sessions-and-rewind.md | 95 +++- internal/manual/chat/starting-codeaf.md | 2 +- .../manual/chat/staying-on-that-machine.md | 4 +- internal/manual/chat/subharnesses.md | 11 +- internal/manual/chat/tasks.md | 8 +- internal/manual/chat/what-i-can-do.md | 4 +- internal/tui3/app.go | 63 ++- internal/tui3/back_test.go | 224 -------- internal/tui3/background.go | 4 +- internal/tui3/background_test.go | 2 +- internal/tui3/bargein.go | 2 +- internal/tui3/bargein_test.go | 4 +- internal/tui3/boxseam_test.go | 6 +- internal/tui3/bundle_test.go | 6 +- internal/tui3/chatnotes_test.go | 4 +- internal/tui3/commands.go | 7 +- internal/tui3/escword_test.go | 2 +- internal/tui3/foot.go | 2 +- internal/tui3/footswap.go | 8 +- internal/tui3/home.go | 419 ++++++++++++--- internal/tui3/home_test.go | 500 +++++++++++++++--- internal/tui3/homearrows_test.go | 8 +- internal/tui3/homeask.go | 21 - internal/tui3/homeask_test.go | 119 ----- internal/tui3/homeaskmd_test.go | 4 +- internal/tui3/homebullets_test.go | 16 +- internal/tui3/homedrop_test.go | 2 +- internal/tui3/homeexchange.go | 23 +- internal/tui3/homeexchange_test.go | 109 ++-- internal/tui3/homegrid.go | 2 +- internal/tui3/homephone.go | 41 +- internal/tui3/homephone_test.go | 10 +- internal/tui3/homeprojectpaste_test.go | 11 +- internal/tui3/homequestionrows_test.go | 8 +- internal/tui3/homeslash.go | 5 - internal/tui3/homeslash_test.go | 36 +- internal/tui3/hop.go | 2 +- internal/tui3/input.go | 33 +- internal/tui3/notice.go | 2 +- internal/tui3/notice_test.go | 2 +- internal/tui3/pages.go | 7 +- internal/tui3/pages_test.go | 18 +- internal/tui3/park.go | 2 +- internal/tui3/park_test.go | 52 +- internal/tui3/pastechip_test.go | 2 +- internal/tui3/payload.go | 2 +- internal/tui3/payload_test.go | 8 +- internal/tui3/place_home.go | 4 +- internal/tui3/place_memory.go | 3 +- internal/tui3/place_search.go | 3 +- internal/tui3/place_sessions.go | 9 +- internal/tui3/place_settings.go | 9 +- internal/tui3/place_spend.go | 9 +- internal/tui3/place_standing.go | 3 +- internal/tui3/place_tasks_test.go | 6 +- internal/tui3/placehint_test.go | 4 +- internal/tui3/placekeys.go | 62 +++ internal/tui3/placemsgline_test.go | 4 +- internal/tui3/render.go | 23 +- internal/tui3/rewind.go | 90 +++- internal/tui3/rewind_test.go | 84 +++ internal/tui3/rewindsheet.go | 12 +- internal/tui3/room.go | 13 +- internal/tui3/roomrecall_test.go | 6 +- internal/tui3/settings.go | 3 +- internal/tui3/slashchip.go | 4 - internal/tui3/steer.go | 4 +- internal/tui3/steer_test.go | 14 +- internal/tui3/stopbound_test.go | 4 +- internal/tui3/stopping_test.go | 109 +++- internal/tui3/subharness.go | 28 +- internal/tui3/subharness_test.go | 13 +- internal/tui3/surface_test.go | 19 +- internal/tui3/tabreopen_recovery_test.go | 10 +- internal/tui3/taskphone.go | 6 +- internal/tui3/taskphone_test.go | 6 +- internal/tui3/tui3_test.go | 4 +- internal/tui3/watching.go | 40 +- internal/tui3/watching_test.go | 5 +- internal/tui3/welcome.go | 2 +- internal/tui3/wiring_test.go | 12 +- 95 files changed, 2080 insertions(+), 1164 deletions(-) delete mode 100644 internal/tui3/back_test.go delete mode 100644 internal/tui3/homeask.go delete mode 100644 internal/tui3/homeask_test.go diff --git a/internal/e2e/questions_e2e_test.go b/internal/e2e/questions_e2e_test.go index bfde7dda04..996a656d62 100644 --- a/internal/e2e/questions_e2e_test.go +++ b/internal/e2e/questions_e2e_test.go @@ -1033,12 +1033,12 @@ func questionsWithdrawn(t *testing.T) { awaitQuestion(t, r, "overwrite the checkpoint?", keyedWord("1", "overwrite it")) shot(t, r, "raised") - // Escape folds the question to the chip; Ctrl+C interrupts the turn - // explicitly, which withdraws the question + // `esc` twice: the first folds the question to the chip (it is LATER, not + // cancel), the second is the surface's own interrupt, which takes the turn // the question was holding open — and with the turn gone the question has // stopped needing an answer. press(t, r, "Escape") - press(t, r, "C-c") + press(t, r, "Escape") gone := r.waitFor(90*time.Second, say(t, "questionWithdrawnWord")) screenSays(t, gone, say(t, "questionWithdrawnMark"), "the withdrawn mark") screenSays(t, gone, "overwrite the checkpoint?", "the withdrawn line names the question that went away") diff --git a/internal/e2e/stopbound_e2e_test.go b/internal/e2e/stopbound_e2e_test.go index 50fd478497..a9518a4e4d 100644 --- a/internal/e2e/stopbound_e2e_test.go +++ b/internal/e2e/stopbound_e2e_test.go @@ -330,14 +330,14 @@ func runRealModelBoundedStop(t *testing.T) { } pressed := time.Now() - rig.keys("C-c") + rig.keys("Escape") stopping := rig.waitFor(10*time.Second, say(t, "stopDetachWord")) - t.Logf("=== REAL MODEL: pane after ctrl+c (the bound, stated) ===\n%s", stopping) + t.Logf("=== REAL MODEL: pane after esc (the bound, stated) ===\n%s", stopping) detached := rig.waitFor(stopBoundPatience, say(t, "stopDetachedWord")) took := time.Since(pressed) - t.Logf("=== REAL MODEL: pane after the detach (%s after ctrl+c) ===\n%s", took.Round(time.Second), detached) + t.Logf("=== REAL MODEL: pane after the detach (%s after esc) ===\n%s", took.Round(time.Second), detached) if took > stopBoundPatience { t.Fatalf("the turn took %s to detach, which is past the bound", took) } @@ -364,14 +364,14 @@ func runStoppedInTime(t *testing.T, stub *stopStub, name, ask string) { waitUntilParked(t, rig, stub) pressed := time.Now() - rig.keys("C-c") + rig.keys("Escape") // THE SURFACE'S WAIT ENDS INSIDE THE BOUND. `interrupted` is the word the // status line takes once the turn is genuinely over (internal/tui3's // render.go), so waiting for it is waiting for the stream to have closed. settled := rig.waitFor(stopBoundPatience, say(t, "interruptedWord")) took := time.Since(pressed) - t.Logf("=== pane %s after ctrl+c: the never-ending stream is over ===\n%s", took.Round(time.Second), settled) + t.Logf("=== pane %s after esc: the never-ending stream is over ===\n%s", took.Round(time.Second), settled) if took > stopGraceE2E { t.Fatalf("the stream took %s to end, which is past the bound", took) } @@ -425,19 +425,19 @@ func runBoundedStop(t *testing.T, stub *stopStub, name, ask string) { waitUntilParked(t, rig, stub) pressed := time.Now() - rig.keys("C-c") + rig.keys("Escape") // 1. THE BOUND IS ON THE SCREEN BEFORE IT FIRES. stopping := rig.waitFor(10*time.Second, say(t, "stopDetachWord")) if !strings.Contains(stopping, say(t, "stoppingWord")) { t.Fatalf("the countdown is drawn without the word it belongs to:\n%s", stopping) } - t.Logf("=== pane after ctrl+c (the bound, stated) ===\n%s", stopping) + t.Logf("=== pane after esc (the bound, stated) ===\n%s", stopping) // 2. AND IT FIRES INSIDE THE BOUND. detached := rig.waitFor(stopBoundPatience, say(t, "stopDetachedWord")) took := time.Since(pressed) - t.Logf("=== pane after the detach (%s after ctrl+c) ===\n%s", took.Round(time.Second), detached) + t.Logf("=== pane after the detach (%s after esc) ===\n%s", took.Round(time.Second), detached) if took > stopBoundPatience { t.Fatalf("the turn took %s to detach, which is past the bound", took) } diff --git a/internal/e2e/tui_e2e_test.go b/internal/e2e/tui_e2e_test.go index dff5e3e598..ebac806b72 100644 --- a/internal/e2e/tui_e2e_test.go +++ b/internal/e2e/tui_e2e_test.go @@ -406,19 +406,27 @@ func testHomeShape(t *testing.T) { } t.Logf("padding row above the foot rule (row %d) is blank", foot-1) - // Open the selected conversation, then return with Escape. Home is the - // final destination even after repeated presses. - r.keys("Down", "Enter") - r.waitFor(15*time.Second, say(t, "homeDoorWord"), say(t, "microcopy")) - r.keys("Space", "Space") - spaces := r.waitFor(15*time.Second, say(t, "homeDoorWord")) - if strings.Contains(spaces, say(t, "placeRestWord")) { - t.Fatalf("two spaces navigated instead of typing:\n%s", spaces) - } - r.keys("Escape", "Escape", "Escape") + // esc closes home into the conversation the launch loaded, and the rule over + // that conversation's box names both doors back. + r.keys("Escape") + closed := r.waitFor(15*time.Second, say(t, "homeDoorWord"), say(t, "microcopy")) + t.Logf("esc closed home into the conversation the launch loaded:\n%s", closed) + if strings.Contains(closed, say(t, "placeRestWord")) { + t.Errorf("esc did not close home:\n%s", closed) + } + r.lit("/home") + time.Sleep(700 * time.Millisecond) + r.keys("Enter") back := r.waitFor(15*time.Second, say(t, "placeRestWord"), "Seed Alpha") - t.Logf("Escape settled on Home:\n%s", back) + t.Logf("/home reopened it:\n%s", back) + // And two spaces on an empty box is the other door. + r.keys("Escape") + time.Sleep(1200 * time.Millisecond) + r.keys("Space") + r.keys("Space") + gesture := r.waitFor(15*time.Second, say(t, "placeRestWord")) + t.Logf("space space opened home:\n%s", gesture) } // ── 2 ─────────────────────────────────────────────────────────────────────── @@ -566,13 +574,19 @@ func testAskHere(t *testing.T) { r.keys("Enter") r.waitFor(20*time.Second, say(t, "placeRestWord")) - r.lit("/ask remind me in 1 minute to drink water") + r.lit("remind me in 1 minute to drink water") time.Sleep(700 * time.Millisecond) typed := r.capture() - if strings.Contains(typed, "? ask here:") || strings.Contains(typed, "+ start a new conversation:") { - t.Errorf("submission action rows remain above the composer:\n%s", typed) + if !strings.Contains(typed, say(t, "homeAskHereWord")+": ") || + !strings.Contains(typed, say(t, "homeStartWord")+": ") { + t.Errorf("the two action rows are not both drawn while something is typed:\n%s", typed) } - r.keys("Enter") + t.Logf("the action rows while typing:\n%s", typed) + + // ctrl+enter, sent as the CSI 13;5u a kitty-protocol terminal sends. It + // hands the keyboard straight to the pane, and a pane holding the keyboard + // always names both ways back out of it. + r.ctrlEnter() pane := r.waitFor(25*time.Second, say(t, "homeAskHereWord"), say(t, "exchangeBack")) t.Logf("the exchange took the screen:\n%s", pane) @@ -612,7 +626,7 @@ func testAskHere(t *testing.T) { // card, which is the case asking-from-home.md states outright: closing home // does not touch it, and neither does opening another conversation. The // keyboard is already on the list, so ONE esc closes home. - r.keys("C-t") // leave Home through the new-conversation page + r.keys("Escape") // home closes into the conversation underneath time.Sleep(2500 * time.Millisecond) r.lit("/home") time.Sleep(700 * time.Millisecond) @@ -913,8 +927,8 @@ func testFiringReachesThePerson(t *testing.T) { // ── the second half: nobody is here when it fires ── // // A firing wakes the conversation it lands in, so the model may still be - // answering it. Ctrl+C stops the turn before the next errand. - r.keys("C-c") + // answering it. esc ends whatever is in flight before the next errand. + r.keys("Escape") time.Sleep(2 * time.Second) openHome(t, r) standReminder(t, r, "remind me in 1 minute to stretch") @@ -1109,7 +1123,7 @@ func testAnswerFromHome(t *testing.T) { // and the clock, because the panels are the counts; in a conversation it keeps // `1 want you` (DESIGN §1 law 11), read on the chat's own ten-second beat. So // B steps into its own conversation, reads its head, and comes back. - b.keys("Down", "Enter") + b.keys("Escape") inChat := b.waitFor(30*time.Second, say(t, "homeDoorWord"), say(t, "pulseWantWord")) t.Logf("window B's own conversation counts the question on its top line:\n%s", firstMatch(inChat, say(t, "pulseWantWord"))) openHome(t, b) @@ -1172,7 +1186,7 @@ func testHover(t *testing.T) { // The seeds share a word, so typing it lists all three; the cursor rests on // the action row, whose card is empty because that chat does not exist yet. r.lit("Seed") - screen := r.waitFor(15*time.Second, "› Seed", "Seed Beta") + screen := r.waitFor(15*time.Second, say(t, "homeStartWord"), "Seed Beta") rows := r.lines() target := -1 for i, line := range rows { @@ -1228,7 +1242,7 @@ func testFold(t *testing.T) { // AND TYPING SEES STRAIGHT THROUGH IT: a search matches every conversation on // the machine, including the ones no panel is drawing. r.lit("Seed T") - found := r.waitFor(15*time.Second, "› Seed T") + found := r.waitFor(15*time.Second, say(t, "homeStartWord")) deadline := time.Now().Add(15 * time.Second) for matchRow(found, "Seed T") == "" && time.Now().Before(deadline) { time.Sleep(500 * time.Millisecond) @@ -1239,7 +1253,7 @@ func testFold(t *testing.T) { } else { t.Logf("typing found the row behind the fold: %q", row) } - r.keys("C-u") + r.keys("Escape") time.Sleep(1500 * time.Millisecond) back := r.capture() if strings.Contains(back, "Seed T") || !strings.Contains(back, say(t, "placeRestWord")) { @@ -1547,7 +1561,7 @@ func testOneSpendFigure(t *testing.T) { } else { t.Logf("FINDING: the live strip was never caught — the turn may have finished first") } - r.keys("C-c") + r.keys("Escape") // The ledger's writer is a background goroutine and both places read the // file on a three-second beat, so the reading is taken after one beat has // certainly turned rather than in the same instant as the keystroke. diff --git a/internal/e2e/tuiwords_test.go b/internal/e2e/tuiwords_test.go index 4a200a2f68..f9ca5a97e7 100644 --- a/internal/e2e/tuiwords_test.go +++ b/internal/e2e/tuiwords_test.go @@ -146,7 +146,7 @@ var tuiWords = map[string]tuiWord{ why: "the promise in home's box with nothing typed into it — the one box on a place", }, "homeDoorWord": { - screen: "esc back", + screen: "space space home", why: "the gesture back to home, named on the conversation's own rule", }, "microcopy": { @@ -223,11 +223,17 @@ var tuiWords = map[string]tuiWord{ screen: "last active ", why: "the facts line on the card beside a search — the one card left once the resting card went", }, + "homeStartWord": { + screen: "start a new conversation", + why: "the action row under anything typed at home", + }, // ── asking from home ───────────────────────────────────────────────────── "homeAskHereWord": { screen: "ask here", - why: "the heading of the home pane opened by /ask", + why: "what one ↑ off the action row starts, drawn as the row's own heading over the pane. " + + "`ctrl+enter` is still bound and is no longer advertised — most terminals cannot send it " + + "and `alt+enter` belongs to the task layer (home.go's [app.homeHintWords])", }, "notifyAskWord": { screen: "waiting on you", @@ -516,7 +522,7 @@ var tuiWords = map[string]tuiWord{ "words home's own row draws, so this gate holds the spelling without a second copy of it here", }, "landingKeysWord": { - screen: "esc back · ctrl+c interrupts or quits", + screen: "esc interrupts · ctrl+c quits", why: "the notice a conversation greets on, and what a window that RESUMED an earlier one draws instead of home", }, "questionWaitingWord": { diff --git a/internal/manual/chat/asking-from-home.md b/internal/manual/chat/asking-from-home.md index 603774489c..bfe2061a6f 100644 --- a/internal/manual/chat/asking-from-home.md +++ b/internal/manual/chat/asking-from-home.md @@ -2,22 +2,17 @@ ## Can I set a reminder from home -Yes. Type `/ask remind me at 6 to leave` on Home and press Enter. Choosing `/ask` -from the command menu writes `/ask `, ready for your question, like `/task`. A bare -`/ask` waits for your words. An inline `/ask` tag works too. +Yes. Type it on the home screen, press `↑` once — which lands on the row spelled +`ask here: "…"` — and press `enter`. ``` + ? ask here: "remind me at 6 to leave" + + start a new conversation: "remind me at 6 to leave" ─ glm-5.3-flash:auto · ◇ asks ─── project: ~/codeaf - › /ask remind me at 6 to leave - alt+p project · alt+e effort · alt+a approvals · alt+k chats · / commands + › remind me at 6 to leave + enter starts a new conversation and sends this · ↑ ask here · ↑↑ pick a match · alt+p project · alt+e effort · alt+a approvals · esc clear ``` -Plain text followed by Enter starts a new conversation by default. Only search results -appear above the seam; the old ask/new action rows and their footer hints are gone. -One Up selects the best result. Down past the last result returns to composing. -Removing `/ask` returns to ordinary submission. From a conversation, `/ask` opens -Home and asks there. A refusal leaves the question editable. - What you get is **a row at the top of home's `threads` panel and a pane holding the exchange**. The row stays there — with what the errand is doing written in its tail — until the errand is finished and you have read what it came to. The pane is the exchange itself: what you said, @@ -51,7 +46,7 @@ done**. They sit at the very **top of home's `threads` panel**, above every conversation — an errand is a thing you asked for a minute ago. `enter` or `→` on the row hands the keyboard to the pane. The hint under the box says so: -`↑↓ move · enter or tab answer this ask here`. (`tab` on the row was the way in +`↑↓ move · enter or tab answer this ask here · esc close`. (`tab` on the row was the way in until the places arrived and took that key for the next place; `→` points at the column the pane is drawn in, which is where the gesture went.) @@ -184,8 +179,8 @@ it: its own keys, which is why one half of the toggle stayed and the other moved to the arrow that points at the pane.) It works from `continue as a conversation` row, an open card. It never loses what is in the pane. -- **`esc`** in the pane hands the keyboard to the list, preserving any half-written - follow-up for when you return. +- **`esc`** in the pane hands the keyboard to the list. One layer at a time: if you have + half a follow-up typed, the first `esc` clears that and the second one leaves. - **`enter`** on the exchange's row in the list hands the keyboard to its pane. - **clicking** puts the keyboard where the pointer is. A click on a list row opens that row, as `enter` would, *and* takes the keyboard to the column; a click anywhere in the @@ -299,7 +294,7 @@ created until you answer it: keyboard goes back to the list. **This is the only way to say no in this pane**: `esc` here hands the keyboard back to the list without answering anything, and a card left standing on the column is not an answer. It is the same key on home's answer row and in a conversation, - where `esc` also defers; `0` explicitly declines. + where `esc` also declines. Those four answers are the only four, and a card draws three of them where `3` is not one it can offer. Each answer is a row of its own and **a click anywhere along it takes that diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index 7a862cdc76..c1b27c5f06 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -22,7 +22,7 @@ A filter with no matches shows `no commands match` and keeps unrelated results h The list follows the caret as well as edits. The box remains editable while it is open. A command chosen inside a sentence completes its token rather than running on its own; -send tags such as `/ask` and `/task` retain their submission behavior. +The `/task` send tag retains its submission behavior. On home, command rows describe what they will do there, including commands that open a conversation first. See *What each command does on home*. In a conversation, pointer @@ -170,7 +170,7 @@ Canonical word, the other words it answers to, its argument form, and what it do | `/land` | — | — | says what has been changed for a folder you chose and is waiting to go into it | | `/land` | — | `now` | …puts it in: a branch merged for a repository, files copied back for a plain folder | | `/land` | — | `` | …when more than one folder is waiting; `/land now` puts that one in | -| `/rewind` | `/undo`, `/back` | — | opens the rewind timeline — the whole conversation as a list (Escape backs out without rewinding) | +| `/rewind` | `/undo`, `/back` | — | opens the rewind timeline — the whole conversation as a list (esc esc is the quick inline version) | | `/permissions` | `/perms` | — | lists what runs without asking; `d` drops a line | | `/standing` | `/orders` | `` | makes those words a standing order — a card to answer, never work done once | | `/standing` | `/orders` | — | what stands over this conversation; `p` pauses, `s` stops, `n` excepts this place | @@ -239,7 +239,7 @@ drawing, and the words are the same. **One gesture, one spelling.** Wherever the sheet names the escape key it writes `esc back` — the places row, the task roster on `alt+t`, the conversation switcher on `alt+k`, -`esc` — and that is the same two words the cards, pickers, the rewind sheet and the +`space space` — and that is the same two words the cards, pickers, the rewind sheet and the switcher's own strip already use. The sheet used to say `esc comes back`, `esc goes back` and `esc leaves` on four different rows, which read as four gestures on the one screen you open to find out how many there are. The longer `esc leaves it as it was` is a different @@ -268,7 +268,7 @@ any filter box, picker or panel: those have the keyboard first, so the key never this binding. The key is named on the first row of `/help` itself, and on the line every session opens -with — `esc back · ctrl+c interrupts or quits · ? for help`. `/?` is also an alias of +with — `esc interrupts · ctrl+c quits · ? for help`. `/?` is also an alias of `/help`, and has been all along. `/quit` (or `/exit`, `/q`) **closes the conversation in front**, and it does it at once — @@ -368,8 +368,11 @@ maintained a little at a time by the reader that runs after each turn. ## /rewind — go back to an earlier point in the conversation `/rewind` (or `/undo`, `/back`) opens the **rewind timeline**: a fullscreen list of the -whole conversation, oldest first, that you pick a point out of. Escape is back navigation; -it no longer opens rewind. The command row reads `go back to an earlier point`. +whole conversation, oldest first, that you pick a point out of. It is the deliberate way +in. The quick way is esc esc, which draws a cut line through the transcript on screen +instead of opening anything — see the sessions and rewind page for both. + +The command row reads `go back to an earlier point · esc esc takes back the last`. On the timeline: ↑↓ move, typing searches, the first `enter` places the pick and the second `enter` on that same point does the rewind, `esc` clears the search and then @@ -1068,14 +1071,16 @@ conversation in them**, which is the one thing `/resume` cannot show you: `/resu "which conversation, here", and this is "what is there at all". **It is also what a bare `codeaf` opens on.** The conversation the launch picked is loaded -underneath; opening its row returns to it. Escape stays on Home. Home stays out of the way when you named a conversation +underneath, and `esc` — or `enter` on the row the cursor starts on, which is that same +conversation — drops into it. Home stays out of the way when you named a conversation (`--session`, `codeaf resume`), on a `--once` or `--host` run, and on a machine whose only conversation is the one already open. There is no welcome box when home greets you. Not -greeting you is not the same as being out of reach: `/home`, or `esc` from a conversation, opens it on a one-conversation machine and on an empty one alike, and over `--host` +greeting you is not the same as being out of reach: `/home`, or `space` twice on an empty +box, opens it on a one-conversation machine and on an empty one alike, and over `--host` it opens the far machine's. There is no argument form. There are three other ways in: **`alt+1`**, home being the first -of the four places on the tab bar; **`esc`** from a conversation; and **`tab`** from any +of the four places on the tab bar; **`space` twice** on an empty box; and **`tab`** from any other place. **It is seven panels**, in one column under 110 cells, two from 110 and three from 170, @@ -1089,10 +1094,11 @@ at the left, and the **rail** at the right holds `projects` and `spend` at its t quiet panels under them. An empty panel keeps its heading and one dim line naming what arrives there. -`↑`/`↓` walk a column, `←`/`→` cross columns, `enter` opens, `esc` dismisses a local layer and otherwise stays on Home. **Typing does two things at once**: what you type is a new +`↑`/`↓` walk a column, `←`/`→` cross columns, `enter` opens, `esc` closes back into the +conversation you came from. **Typing does two things at once**: what you type is a new conversation waiting to be sent AND a live search over every project on the machine — the -panels give way to the matches, with none selected until you navigate into them. -Type-and-enter starts a chat. `/ask ` asks in a home pane instead. **A line that starts with `/` is +panels give way to the matches, with `start a new conversation: "…"` directly above the box +holding the cursor, so type-and-enter still starts a chat. **A line that starts with `/` is the third thing typing can be**: a command, run rather than sent (see *Typing a slash to see the command list*). The box says `› type to search or start something new` and the foot names the available draft controls: @@ -1118,8 +1124,8 @@ no conversation matches that folder is gone · ``` -`no conversation matches` is a search that found nothing; Enter still starts a new -conversation with your words. `/new is unavailable here` is what the typing-to-start box says where no +`no conversation matches` is a search that found nothing — the `start a new conversation` +row is still there. `/new is unavailable here` is what the typing-to-start box says where no fresh-session seam exists. The last is `enter` on a row whose folder has been deleted or moved since its last conversation: home stays up and nothing is opened. **How many conversations this terminal already holds is never a refusal.** Past twelve, a quiet @@ -2078,16 +2084,3 @@ you closed*, including what a terminal that cannot send the key does instead. `alt+k` is the other way back: it lists every conversation on this machine, closed tabs included, and opening a row brings the tab and its draft back too. - -## /ask — ask from home without opening a regular conversation - -Type `/ask ` and press Enter to ask in a home pane. Choosing `/ask` from the -command menu inserts `/ask ` and leaves the question for you to write, like `/task`. -Bare `/ask` waits for your question. An inline `/ask` tag in a sentence works too, and -is removed before sending. Multiple active submission tags keep the draft for correction. -From a conversation, `/ask` opens Home and uses the same ask pane. - -Plain text on Home starts a new conversation by default. Only search results appear -above the seam: one Up selects the best match, Enter opens a selected result, and Down -past the last result returns to composing. The old ask/new action rows and their -footer hints are absent. diff --git a/internal/manual/chat/compacting-over-and-over.md b/internal/manual/chat/compacting-over-and-over.md index 7c598106d2..0a315897b4 100644 --- a/internal/manual/chat/compacting-over-and-over.md +++ b/internal/manual/chat/compacting-over-and-over.md @@ -134,7 +134,7 @@ foldable material above the target. The pass still succeeds with what it took, a honestly, because there was nothing more to take. That is the one case where two passes in quick succession are not a defect. -## What happened to the earlier messages — where did the folded messages go — how do I get the compacted text back, why it loses the earlier part of our chat +## Where did the folded messages go — how do I get the compacted text back, why it loses the earlier part of our chat They are still on disk. A fold replaces the oldest assistant work in the model's window with one line such as diff --git a/internal/manual/chat/empty-screen.md b/internal/manual/chat/empty-screen.md index 5939c1c282..6591997bdf 100644 --- a/internal/manual/chat/empty-screen.md +++ b/internal/manual/chat/empty-screen.md @@ -163,11 +163,11 @@ a row opens it too. On a short window the list is cut to the rows that fit. **When there are none, nothing is drawn** — no heading, no `no recent sessions` line, no rows held open. A fresh machine sees the wordmark, the model line, the box and the try line, and that is all. Every earlier conversation, in every project, is on the home -screen (`/home`, or Escape from the conversation) and in `/resume`. +screen (`/home`, or space twice on an empty box) and in `/resume`. -## The line about esc and ctrl+c — when does "esc back · ctrl+c interrupts or quits" appear +## The line about esc and ctrl+c — when does "esc interrupts · ctrl+c quits" appear -The empty screen carries no line about leaving. `esc back · ctrl+c interrupts or quits · ? +The empty screen carries no line about leaving. `esc interrupts · ctrl+c quits · ? for help` lands as the first dim line of the conversation the moment the greeting goes — your first keystroke — where it sits directly above the box you have just started typing into. A conversation that opens on a transcript, such as a resumed one, has it on its diff --git a/internal/manual/chat/hints-and-tips.md b/internal/manual/chat/hints-and-tips.md index a8a4452472..e6c6e4ad0a 100644 --- a/internal/manual/chat/hints-and-tips.md +++ b/internal/manual/chat/hints-and-tips.md @@ -4,11 +4,11 @@ The dim row under your message box — the last row of the frame — is the hint slot (until 2026-09-17 it was the right end of the rule above the box; the numbers have that end now). -Most of the time it names the keys that work right now — `ctrl+c interrupt` while an answer is coming, +Most of the time it names the keys that work right now — `esc interrupt` while an answer is coming, `y allow · n deny · a always` while codeaf is asking you something, `/ commands` when nothing is happening. Once you have used codeaf a little, that idle line sometimes carries a tip instead: one sentence naming a key or a command you have not used yet, and what it does. -For example `ctrl+. sees every task this project has run`, or `/rewind takes back an earlier +For example `ctrl+. sees every task this project has run`, or `esc esc takes back the last message`. A tip only appears over an empty box while nothing else is happening. The moment you type, @@ -41,8 +41,8 @@ that retires it. list by typing `/`. - `ctrl+. sees every task this project has run` — after the first task starts. Retired when you open the task page, by `ctrl+.` or `/history`. -- `/rewind takes back an earlier message` — after an answer of about 1,500 characters or more. - Retired the first time a rewind lands, from `/rewind`. +- `esc esc takes back the last message` — after an answer of about 1,500 characters or more. + Retired the first time a rewind lands, from `esc esc` or from `/rewind`. - `/compact summarizes the conversation now` — when the conversation passes half its context window. Retired when a `/compact` finishes. - `/files finds everything made for you` — after the first `/export` writes a file. Retired @@ -63,7 +63,7 @@ the cost tip over the task page tip — and the other waits its turn. Open the settings panel with `/settings` (or `ctrl+,`), go to the **Display** tab, and flip the **hints** row off. Enter or space toggles it. The change lands at the end of the next turn. Off silences the tips and the what's-new lines together; it does not touch the keys -the slot names for a live state — `ctrl+c interrupt` and the rest are not hints and cannot be +the slot names for a live state — `esc interrupt` and the rest are not hints and cannot be turned off. Turning the row back on shows whatever is due. Tips you had already retired stay retired. diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index a41af8ecbb..7783bebd85 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -44,8 +44,8 @@ most of those left-hand panels have nothing in them, and they gather under the p in the right-hand rail instead — heading and one dim line each — so the left of the screen is only ever the things that are actually going on. -Escape stays on Home once its local layers are dismissed. Open a conversation row -or use `alt+k` to return; drafts and work stay intact. The resting foot reads +`esc` puts you back in exactly the chat you came from, untouched — nothing was closed and +nothing was sent while you were looking. The resting foot reads `alt+p project · alt+e effort · alt+a approvals · alt+k chats · / commands`. The project, approvals and chats hints appear only where those controls can act. The controls stay the same as the cursor walks between rows. `→` opens the selected @@ -364,8 +364,9 @@ Home's open rows, the tab strip and the default chats menu immediately. It keeps running work and drafts. A waiting conversation carries a `?`; the question remains reachable even when its tab is closed. -`/ask` exchanges follow the conversation rows. Escape opens Home; Enter opens the -selected row. Further Escape presses stay on Home. +**On `space` `space` the cursor is already on the chat you were in before this one**, so a +switch back is two keys — `space` `space`, then `enter` — and `esc` still goes back to the +conversation behind home. ## Start a chat in another folder — the projects panel is read, not pressed; ctrl+t starts a chat elsewhere @@ -502,7 +503,7 @@ own tab stack — so going back is `enter`. A window that has held only one conv "before", and the cursor is on its own row in the conversation list, which says `here`. **On a launch** — home greeting you — the cursor is on the conversation this window is -holding, the row you can open to return. +holding, the row `esc` drops back into. **`↑` off the top row of home stays on it.** The tab bar — the row of four words — is reached by clicking a word, by `tab`, or by a place's own chord (`alt+2` and the rest); on @@ -531,8 +532,8 @@ A quiet morning on a busy machine is the same screen with fewer rows: `needs you `sessions` whispering, the conversation list full, `since you left` holding what fired overnight. There is no accent anywhere when nothing is waiting on you. -Typing works exactly as it does anywhere: Enter starts a conversation and `/ask` asks -in a home pane. Only search results appear above the seam. +Typing works exactly as it does anywhere: `? ask here: "…"` and `+ start a new +conversation: "…"` rise out of the box, and `enter` starts the conversation. Over `--host`, in the fraction of a second before the far machine answers, home draws no panels at all — a whisper over a server full of work would be untrue. @@ -640,12 +641,13 @@ tasks, `spend` of spend, `scheduled` of standing. ## Why did a dashboard open when I started codeaf — home greets you **Home is the first thing you see when you open codeaf.** The conversation your launch -would have opened is loaded and waiting underneath it: opening its row returns to it. In +would have opened is loaded and waiting underneath it: `esc` drops straight into it. In effect the launch is the launch you always had, with home already open on top of it. **The cursor starts on the conversation this window is holding** — its row in `where you were`, wearing `here` — so the first frame already answers "where am I". `↑` off the top of -the column stays there (see *Where the cursor starts*); opening a conversation row resumes it. +the column stays there (see *Where the cursor starts*); `esc` goes on with +what you were doing. Nothing about *which* conversation opens is changed by this. The door picks it exactly as it always did — this directory's most recently spoken-in chat, or a fresh one — before home is @@ -677,7 +679,7 @@ There is no setting for this and no flag to turn it off: whether home greets you from how you launched and what the machine holds, both of which answer themselves. Not being greeted is not the same as being out of reach. Once you are in a conversation, -`/home` — or `esc` from the conversation — opens the screen whenever you want it, on a +`/home` — or `space` twice on an empty box — opens the screen whenever you want it, on a machine with one conversation and on one with none (see *Why is the home screen empty*). ## Close or archive a conversation — put junk away and clean up home @@ -704,16 +706,19 @@ Home has **two shapes**. on the chat you were in before this one. **The moment you type a character it becomes one list, a drop-up.** It lifts so that its -best match lands nearest the message box. For ordinary text, only search results appear above the seam; -there are no submission action rows. One `↑` selects the strongest match, and each `↑` -past it walks into a weaker one. Clearing the box puts the panels back. +last row — the action row, `start a new conversation: "…"` — lands directly above the box +you are typing into, with `ask here: "…"` between it and the matches, and the matches rise +above the pair **best one first**: the strongest match is two `↑` away, and each `↑` past it +walks into a weaker one. Everything to do with typing is then one cluster at the foot: your +words, the row saying what `enter` will do with them, and the hint under it. Clearing the +box puts the panels back. **So the cursor does move between the two**, from up in the panels to the foot and back. One keystroke of re-anchoring is cheaper than a page of panels pinned against the box. **On a frame 136 columns or wider, a card stands beside the matches** — about the match under the cursor (*The card beside a search*). It never moves while the list lifts, and it -stays empty until you select a result. +goes empty on the `start a new conversation` row, which is a chat that does not exist yet. ## Switch between sessions — enter on home @@ -725,7 +730,8 @@ place opens that place. A click on a panel's **heading** opens the place the hea `scheduled` opens standing. There is no `needs you` heading. The conversation list has no heading. The `projects` heading opens nothing and stays dim. **A heading that opens somewhere underlines on mouse-over.** The pointer on a heading moves neither the cursor nor the marked heading. -A click on a `/` command only selects it; `enter` runs it. +A click on a `/` command, `ask here` or the new-conversation row only selects it; +`enter` runs it. `enter` opens the session under the cursor — **any row on the screen, in any project.** The chosen journal is opened and replayed, and **the conversation you were in stays open @@ -748,7 +754,7 @@ alt+p project · alt+e effort · alt+a approvals · alt+k chats · / commands promise moved on 2026-09-17: the lowest line is for keys) and it says what THAT row's keys do on a row that has its own — a `since you left` line, -a fold door — with the available draft controls before `esc`. +a fold door or the action row — with the available draft controls before `esc`. The foot omits the `ctrl+o` and `tab` hints; both keys still work. ## Typing a long question on home — does the box wrap, and where does a paste go @@ -777,9 +783,11 @@ opens the **composer layer**, where the three facts a task needs are settled — what, how much (the places page, *the composer layer*) — and a second `alt+enter` sends it off. -**`/ask ` asks here.** Enter sends the question to its own home pane. -Plain text plus Enter starts a new conversation. `ctrl+enter` remains an unadvertised -shortcut for asking here on terminals that can send it. +**`ask here` is one `↑` and then `enter`.** The row is already on the screen while you +type: `? ask here: "…"` sits directly above `+ start a new conversation: "…"`, and the +cursor rests on the lower of the two, so the ask is one keystroke up. `ctrl+enter` is still +bound to it and is no longer named on the foot, because only a terminal that can tell +`ctrl+enter` from a plain `enter` ever sends it. **A paste lands in home's box.** Paste while home is open and the text goes into the foot box — searching, exactly as typing does — or into the ask-here exchange's own box when that @@ -814,7 +822,7 @@ sentence in the other window's box arrives in yours. **`codeaf chat` in a folder whose conversation is open elsewhere** — you opened codeaf and it said `open in another window` — does not start a second one silently. It opens home with that row pointed at, so one `enter` continues where you -left off and typing and submitting a new message starts a conversation instead. A plain `codeaf` in a folder +left off and `esc` gets on with a new conversation instead. A plain `codeaf` in a folder whose engine is already holding a conversation simply **sits down in the one the engine has**. @@ -824,7 +832,7 @@ has**. cannot happen at all**: the row says `open in another window — go there, or start a new conversation here`. -## I pressed enter twice on the held row and it did not move — moving a conversation from a window with no engine asks first — the move card, enter moves nothing, the cursor starts on leave it there +## Moving a conversation from a window with no engine asks first — the move card, enter moves nothing, the cursor starts on leave it there This is the road a window takes when there is no engine holding the conversation — `--no-host`, `--debug`, a test. On the ordinary `codeaf chat` you will not meet it: see @@ -1121,8 +1129,8 @@ which beats a word inside it, which beats the letters appearing in order. Then t break ties: a conversation **waiting on you** beats a cold one it ties with, whatever their ages, and after that the more recent one wins. -`↑`/`↓` walk the matches, `enter` opens the highlighted one. Escape preserves the draft and stays on Home. Clear the box with `ctrl+u` to restore -the unfiltered panels. +`↑`/`↓` walk the matches, `enter` opens the highlighted one. `esc` clears the box and puts +the panels back; a second `esc` closes home. **The matches grow upward out of the box, best one first** — see *Why is the best search result at the bottom* below. On a frame 136 columns or wider the card beside them follows @@ -1131,7 +1139,8 @@ either. ## Why is the best search result at the bottom — the order of the matches -**The strongest match is directly above the seam, so one `↑` selects it.** Each `↑` past that walks into a +**The strongest match is the first conversation above the two typing rows — `ask here` and +`start a new conversation` — so two `↑` get you to it.** Each `↑` past that walks into a weaker match, and `↓` comes back down toward the box. That is upside-down next to an ordinary ranked list, and deliberately so. A list you read @@ -1156,8 +1165,10 @@ keystroke with nothing loaded and no model called. Two things cover what a meaning-search would have been for: -- **Enter starts a new conversation by default.** Even when the search finds nothing, - your words can become the first message of a new chat. +- **The row that offers to start a conversation never goes away.** A query that matches + nothing still reads `start a new conversation: "…"` above the box, so the worst case of a + search that missed is that your words become the first message of a new chat — which is + very often what you wanted. - **Ask the chat instead.** It has a `tasks` tool over the whole project record and you can ask it in sentences: *"what was that thing where we fixed the flaky auth test?"* Home is the fast layer; the conversation is the thoughtful one. @@ -1168,19 +1179,26 @@ Whatever you type is **three things at the same moment**: a new conversation wai sent, a live query over the machine, and — if it starts with `/` — a command. You do not choose between them before you start typing. -**Only search results appear above the seam.** The best result is nearest the box. -No result is selected while you compose, so Enter starts a new conversation and sends -your words. Use `/ask ` to ask in a home pane instead. +**Everything about typing sits together at the bottom of the screen.** The moment you type +a character the panels give way to a drop-up: the matches rise from the foot, and the +**last** row of the list is the action row — `start a new conversation: "…"` with your words +quoted back — sitting directly above the box you are typing into. ``` alpha ○ Pricing Sheet Import 2h - ○ Pricing 12m ← one ↑ + ○ Pricing 12m ← two ↑ + + ? ask here: "pricing" + + start a new conversation: "pricing" ─ glm-5.3-flash:auto · ◇ asks ─── project: ~/codeaf › pricing - alt+p project · alt+e effort · alt+a approvals · alt+k chats · / commands + enter starts a new conversation and sends this · ↑ ask here · ↑↑ pick a match · alt+p project · alt+e effort · alt+a approvals · esc clear ``` +**The cursor rests on the action row by default.** So typing and pressing `enter` starts a +fresh conversation and sends what you typed, however many matches are on screen. + **Where it opens is on the rule above the box, and `enter` honours it.** That line reads `glm-5.3-flash:auto · ◇ asks ─── project: ~/src/parser`: the model, effort and approvals start at the left, with `project: ` at the far right naming where the conversation will open. @@ -1190,8 +1208,11 @@ project's row and the rule re-points — and with nothing under the cursor it is own project. `alt+p` pins it, `/model` pins the model, `alt+e` walks the rung and `alt+a` walks the gate; *Change the model before starting* has the whole of all four. -One `↑` selects the best result; Enter then opens that result. Walking `↓` past -the last result returns to composing. Neither submission mode adds a footer hint. +One `↑` steps off that row **up** onto `ask here: "…"`, which answers the same sentence in +the pane on the right instead of opening a conversation for it — see *Asking from home*. A +second `↑` reaches the best match. The hint under the box tracks which of the two `enter` +means: the line in the example above on the action row, and `enter open · ↓ back to +starting a new conversation · esc clear` once you are on a match. A line beginning with `/` is the third thing typing can be — a command, run rather than sent. See *Running a slash command from home*, directly below. @@ -1203,7 +1224,8 @@ anywhere**: on `new session failed: ` home closes and your words are put message box unsent — never delivered to the conversation this window was already holding. **Pasting a folder path into an empty home box offers one Enter to start there.** -The complete paste must name one existing local directory. If the next key is Enter, it opens a +The complete paste must name one existing local directory. The action row reads +`start a new conversation in `. If the next key is Enter, it opens a conversation there without sending the path as a message. Any other key — including space, an arrow, Backspace, a shortcut or Shift+Enter — cancels the offer and keeps normal editing behavior. A second paste also cancels it. The remaining text is an ordinary @@ -1221,7 +1243,10 @@ as commands. The folder notice may remain, but the path stays in the box. it.** Typing `/settings` on home and pressing `enter` opens the settings panel; it does not start a conversation whose first message is the word `/settings`. -A fully typed command runs on Enter without an extra action row or footer hint. +**The screen says which `enter` you are about to press, before you press it.** With a command +in the box the action row reads `+ run /settings` in place of `+ start a new conversation: +"…"`, and the foot under the box reads `enter runs this command · ↑ ask here · +↑↑ pick a match · esc clear`. **Every command has a FATE here, and the list says which before you press `enter`.** Each row of the `/` drop-up reads ` · ` — so `/compact` says @@ -1234,7 +1259,6 @@ mixed in. Keep typing to filter; the best name match is selected without changin order. ↑ / ↓ choose, PgUp / PgDown and the mouse wheel scroll, and Enter takes the row. A command that takes words leaves `/model ` in the box ready for its argument. Esc clears the home draft. Moving past the command word into its arguments restores ordinary search. -An inline `/ask` tag also asks the sentence here, just as `/task` marks work to send off. Other ordinary slash-command mentions remain prose. *Typing a slash to see the command list* describes the shared token and filtering rules. @@ -1242,8 +1266,9 @@ list* describes the shared token and filtering rules. `start a new conversation in ` only until the next key. Enter accepts; any other key returns it to ordinary message text. See *Start something new from home*. -**You can ask about a command instead of running it.** Type `/ask what does /settings do?` -and press Enter. The question goes to the pane; `/settings` is not executed. +**You can ask about a command instead of running it.** Type it and press `↑` then `enter` +— the `ask here` row — and the answer comes back in the pane on the right without the +command being run (see *Asking from home*). ## What each command does on home — the fate on every row of the / list @@ -1364,32 +1389,101 @@ Typed on home — bare, or with a path after it — it opens the folder browser already changed. `/place` and `/dir` are the same command. Nothing on that sheet touches the conversation behind home. -## How do I get back to the dashboard or the home screen from any page — Escape +## How do I get back to the dashboard or the home screen from any page — press space twice + +**From inside any conversation, press the space bar twice with an empty message box.** +That is the way back to home, and it lands with the cursor on the chat you were in before +this one. + +There is no `ctrl+` chord for it: every `ctrl+` this surface has is already taken, +and `esc` was not available either — on an idle conversation it already arms rewind and +already drops a message you parked. What was left is the one keystroke that reliably means +nothing: a message that starts with two spaces is a message nobody meant to send that way. + +**The first space types itself, plainly.** It is the *second* space, arriving to find a box +that still shows nothing with that space behind the cursor, that takes the whole draft away +and opens home. So a space you actually wanted is never eaten: space then `x` leaves ` x`. -Press `esc` to go back one layer: close a picker, leave an editor or room, or put a -question aside. With no layer left, Escape opens Home. Further presses stay on Home. -Message drafts, running turns and queued messages are preserved. Filters may clear first. -Escape never starts rewind or stops a turn. `ctrl+c` interrupts a running turn and quits -when idle; `/rewind` opens the rewind timeline. +**Wherever the door is drawn, two spaces open it.** That includes a box holding only blank +lines, from a `ctrl+j` or an `alt+enter` you did not mean. It also includes the other +places: the same two spaces, typed into a place's own empty box — the tasks roster's filter, +the memory filter, the search and spend composers — open home from there. The door still +loses to a space that already means something where you are standing: on the settings panel +space is the row's `activate` verb, inside a task's record `space` pages the card, and on +home itself two spaces type into home's own box. -The double-space binding has been removed. Spaces type normally in message boxes. -`/home` and `alt+1` (`opt+1` on a Mac) also open Home. Open a conversation row or use -`alt+k` to return to a conversation; Escape does not leave Home. +It works with a turn running — `esc` puts you back in it, still running. It does nothing +when the box already has words in it. It works on a machine with one conversation, on one +with none, and over `--host` — where what opens is the **far machine's** home. + +## What does pressing space twice do — the home door at the foot of a conversation + +When the box is empty, the keys row under the box — the last row of the frame — reads +exactly: + +``` +/ commands · space space home +``` -## What does pressing space twice do — space space does nothing now +That is the whole advertisement. It costs no extra row — it is the keys row the frame +already has — and it **vanishes the moment you type anything**, because it is a door +and not decoration. It also goes while a turn is running, where the same row has something +more urgent to say (`esc interrupt`); the gesture still works then, it is just not being +advertised. (Until 2026-09-17 these words were the right end of the rule above the box.) -Two spaces are ordinary text. The old Home shortcut is removed. Use `esc` to back out -to Home, even when a draft is nonempty or work is running. +**You can click it.** A press on the words `space space home` opens home; a press on the +rule beside them is a press on a rule. + +It appears on a fresh machine too, from the first minute, and over `--host` as well: a +machine with one conversation or with none still has a home to go to. The rule that keeps +home from *greeting* a first run is a different rule — not being greeted by home and not +being able to reach it are two different things. + +## space space does nothing — why the gesture did not open home + +Three reasons, and neither the machine holding nothing nor `--host` is one of them any +more: + +- **The box had words in it.** The gesture fires only when the second space arrives to find + a box with nothing in it a person would call text. ` x` and then two spaces is a draft. + The dim line at the foot is the honest test: if it reads `space space home`, two spaces + open home. +- **It was a paste.** Pasted text arrives whole and never reaches the key router, so two + leading spaces in a paste are two spaces (*Is there a key for home?*). +- **Home is already open.** On home, space is a character in the search box. + +A machine with one conversation, or with none, opens home all the same: its panels keep +their headings and the dim lines naming what arrives there (*Why is the home screen empty*), +not a refusal. So does a session over `--host`, which opens the **far machine's** home. ## how do I get back to home with one chat -Escape backs out to Home even on a machine with one conversation or none. Over `--host` -it opens the far machine's Home. `/home` and the clickable `esc back` hint work too. +Three ways, and they all work from the first minute on a fresh machine: + +- `space` twice on an empty message box +- `/home` +- a click on the words `space space home` in the dim line above the box + +The launch itself does not greet you with home while the only conversation on the machine +is the one it just opened — that is a rule about greeting, not about reach — so on a +machine with one chat, home is something you go to rather than something you land on. +What you find there is that chat as the first row of `threads`, saying `here`, and +this folder as the first row of `projects`. ## Is there a key for home? -Escape backs out one layer at a time until Home. `alt+1` (`opt+1` on a Mac) and `/home` -open Home directly where the current layer accepts those controls. +Three of them. **`alt+1`** goes straight there from anywhere — home is the first of the +four places on the tab bar, and each answers to its own position, `alt+1` through `alt+4` +(the three places off the bar answer `alt+5` through `alt+7`). +**Space twice on an empty box** goes there from inside a conversation, and **`tab`** walks to +it from any other place. `/home` opens it too. + +`alt+` arrives in every terminal codeaf runs in — it is sent as escape-then-digit and +has been for forty years — which is why the place keys are on `alt`. `ctrl+` has no +encoding a terminal can send at all. + +There is still no `ctrl+` chord for home: the plain ones are all taken (`ctrl+.` is the +tasks place, `/history`). ## What landed while I was away — since you left, and the look stamp @@ -1718,18 +1812,11 @@ you go to confirm it really did look. ## Ask here — a reminder or a watch without opening a conversation -Type `/ask ` and press Enter to answer the sentence **in a pane of its own**. -The `/` menu offers `/ask `; choosing it writes `/ask ` and leaves the caret -ready for your question, like choosing `/task`. A bare `/ask` also waits for your words. -You can put `/ask` inside your sentence as an active command tag; it is removed before -the question is sent. More than one active submission tag keeps the draft and asks you -to choose one. Removing the tag restores ordinary submission. - -An ask is a real conversation with a transcript kept outside `~/.codeaf/v3/projects`, so -a one-off errand does not become an ordinary chat row. `/ask` from a conversation opens -Home and asks there too. If asking is unavailable, the draft remains editable. The -existing `ctrl+enter` shortcut still works on Home. No ask/new action rows or mode hints -appear above or below the message box. +While you are typing, the row directly above `start a new conversation` is +`ask here: "…"`. It answers the sentence **in a pane of its own** — a real conversation +with a real transcript, kept outside `~/.codeaf/v3/projects` so home never grows a session +row for a one-off errand. One `↑` reaches it, and `ctrl+enter` does it without leaving the +box. **Every exchange is a row below the conversation rows**, marked `?`, because it is the thing you asked for a minute ago — with what it is doing in the tail: @@ -1797,8 +1884,8 @@ drawn only when it has something to say: 9. one dim line naming the strip: `→ verbs: close, new in project, open folder, copy project`. -It never moves while the list lifts under your typing, and it stays empty until a -search result is selected. A frame too short for +It never moves while the list lifts under your typing, and it goes empty on the +`start a new conversation` row, because that chat does not exist yet. A frame too short for all of it drops bands from the bottom and never touches the name. Nothing that is zero is drawn. @@ -2107,7 +2194,7 @@ Top to bottom: 1. `sessions`: the fifteen most recent conversations, combining open tabs and saved history without duplicates. 2. Additional question rows, without a heading — conversations not already listed above, - standing items that need a look, and `/ask` panes holding a card, from **any** project. + standing items that need a look, and ask-here panes holding a card, from **any** project. 3. Home ask exchanges retain their own answer rows. 4. `since you left` — what landed while you were not in the room. 5. **The projects.** This window's own project is drawn open with its remaining rows; every @@ -2124,8 +2211,8 @@ in the same chronological list. Other inbox rows are **two lines** — the label indented under it. A tab with a pending question carries an amber `?` on its existing row, including when the question belongs to a task inside that conversation. -**Typing still searches**, exactly as at every other width. Enter starts a conversation -by default; `/ask ` asks in a home pane. Only results appear above the seam. +**Typing still searches**, exactly as at every other width, with `? ask here` and +`+ start a new conversation` against the box at the foot. A tap on `since you left` opens memory. Other section headings fold their section away. Mouse motion does nothing at this @@ -2174,7 +2261,8 @@ session offered**, a window this one cannot reach is not answered, and once a ke the band reads `answered · waiting for it to pick that up` until the other session takes it. A window with no way to leave an answer draws no bands at all. -The bar under the box is the phone's legend: `open` on the inbox, `‹ back · open · more` on a sheet, `‹ back · send · more` on an errand. +The bar under the box is the phone's legend: at most three wide targets — `open · new · ask +here` on the inbox, `‹ back · open · more` on a sheet, `‹ back · send · more` on an errand. Tap one, or press the key it names. Below width **24** the plain hint line is drawn instead. ## Main chat versus subtasks — why is the work nested on Home? @@ -2221,7 +2309,7 @@ clears that unread state. Recently closed rows keep dim bullets. These indicator use this window’s live conversations; they do not infer unread history from other windows or persist read status across restarts. -In `/ask`, the thinking, writing and running indicator starts animating as soon as +In ask here, the thinking, writing and running indicator starts animating as soon as you submit, including follow-up messages. Linear mode keeps a still mark. ## How does Sessions behave on a short Home screen diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index ed9d6e11fd..f9cd0cafa6 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -84,7 +84,7 @@ one. |---|---| | `enter` | stops the current generation and sends the words into this turn | | `cmd+enter` | holds the message for an ordinary turn after this answer | -| `ctrl+c` | stops the answer and clears both waiting-message queues | +| `esc` | stops the answer and clears both waiting-message queues | | `→` over an empty box | steers the oldest waiting words into the running answer | | click `→ steers it in` | the same, with the pointer | | `ctrl+shift+enter` instead of `enter` | stops the answer and sends the sentence in one key — see below | @@ -96,7 +96,7 @@ Attachments in the tray go with the held message, and come back on the tray if y it back. `/`-commands are **not** held: a slash command is something you said to this surface rather than to the model, and it runs at once. -**Limits.** `ctrl+c` interrupts only while a turn is running; at rest it quits. +**Limits.** `esc` with nothing waiting is exactly the plain interrupt it always was. With messages waiting, it clears both the editable parked queue and the `ctrl+q` follow-up queue. Each nonempty queue says what was dropped — `1 waiting message dropped` or `N waiting messages dropped` for parked messages, and the corresponding `queued` @@ -166,11 +166,11 @@ line does not offer `→ steers it in`. right end of the row under the message box reads exactly: ``` -enter steers it in · ctrl+shift+enter stops and sends · ctrl+c interrupt +enter steers it in · ctrl+shift+enter stops and sends · esc interrupt ``` That is the terminal-capable form when no command can be kept. A running foreground -command adds `ctrl+g backgrounds` immediately before `ctrl+c interrupt`; a terminal that +command adds `ctrl+g backgrounds` immediately before `esc interrupt`; a terminal that cannot deliver `ctrl+shift+enter` leaves that clause out. `cmd+enter` still waits, but the one-line slot no longer advertises it. @@ -291,25 +291,25 @@ of ending work is `x` and a card that asks first. The chord is ignored there. can tell it apart from a plain `enter` — the kitty keyboard protocol, xterm's modifyOtherKeys, or win32-input. Where it cannot, the key arrives as an ordinary `enter` and your message **steers** instead. On those terminals codeaf never advertises the -chord. Use `ctrl+c` to stop the whole turn, then send the next message normally. +chord. Use `esc` to stop the whole turn, then send the next message normally. **The line that teaches it.** While a turn is running and you have typed something, the right end of the row under the message box reads exactly: ``` -enter steers it in · ctrl+c interrupt +enter steers it in · esc interrupt ``` On a terminal that can spell the secondary chords, the line reads -`enter steers it in · ctrl+shift+enter stops and sends · ctrl+c interrupt`. A foreground +`enter steers it in · ctrl+shift+enter stops and sends · esc interrupt`. A foreground command that can be kept inserts `ctrl+g backgrounds` before the final stop clause. **A picture on the tray is a message even when the box has no words.** It cannot steer, so -that form reads `enter waits · ctrl+c interrupt`, or -`enter waits · ctrl+shift+enter stops and sends · ctrl+c interrupt` on a terminal that can +that form reads `enter waits · esc interrupt`, or +`enter waits · ctrl+shift+enter stops and sends · esc interrupt` on a terminal that can spell the secondary chord. With neither words nor a picture, the line is simply -`ctrl+c interrupt`, unless a command can be kept, when it is -`ctrl+g backgrounds · ctrl+c interrupt`. +`esc interrupt`, unless a command can be kept, when it is +`ctrl+g backgrounds · esc interrupt`. ## I typed while it was working — did my message get lost? @@ -335,25 +335,17 @@ The one thing that is not answered is a message you queued with `ctrl+q` for a turn you then **interrupted**. A drain never restarts a turn you stopped, so those are dropped — press `enter` again to send it. -## Escape, esc, back, and getting home without stopping work +## Leaving for Home with a waiting message -Press `esc` to go back one layer: close a picker, leave an editor or room, or put a -question aside. With no layer left, Escape opens Home. Further presses stay on Home. -Message drafts, running turns and queued messages are preserved. Filters may clear first. -**A message waiting above the box is preserved too, with its pictures, and still goes when -that answer ends even while Home or another chat is in front.** The exception is a -connection that holds one conversation at a time: its waiting words and pictures return -to the box and tray because the old conversation has ended. Escape never starts rewind or -stops a turn. `ctrl+c` interrupts a running turn and quits when idle; `/rewind` opens the -rewind timeline. - -The double-space binding has been removed. Spaces type normally in message boxes. -`/home` and `alt+1` (`opt+1` on a Mac) also open Home. Open a conversation row or use -`alt+k` to return to a conversation; Escape does not leave Home. +A message waiting above the box stays with its conversation when you open Home with +`space` `space`, `/home`, or `alt+1`, and still sends when that answer ends. Its pictures, +pasted documents and standing mark stay with it. A connection that holds one conversation +at a time returns the waiting words and pictures to the box and tray when switching ends +the old conversation. `esc` stops the answer and drops waiting messages. ## Interrupting a running turn — how do I stop it mid answer -Press `ctrl+c` while a turn is running. The turn +Press `esc` or `ctrl+c`. While a turn is running, both do the same thing: the turn is stopped and everything it already said is kept. What happens: @@ -365,7 +357,9 @@ What happens: dropped`, or `N queued messages dropped`. 4. The status word becomes `stopping`, then `interrupted`, and `interrupted` stays as the status word until the next turn starts. -5. Parked messages are dropped too. Escape preserves both queues and goes back instead. +5. If a message of yours was **waiting** for that answer, it is *not* dropped: it sends + immediately as the next turn. That is the whole difference `esc` makes while + something is waiting. **The words codeaf uses for one stop.** They are five slots and one key press, so they are worth reading together: `stopping` is the status word while the turn is being let go, @@ -378,20 +372,23 @@ you are looking for the word *interrupted* anywhere else on the screen, that is is — the status line, and only after the turn has truly ended. **What the screen says.** While a turn runs, the right end of the row under the -message box ends with `ctrl+c interrupt` — for example -`enter steers it in · ctrl+shift+enter stops and sends · ctrl+c interrupt` while you have +message box ends with `esc interrupt` — for example +`enter steers it in · ctrl+shift+enter stops and sends · esc interrupt` while you have typed something and this terminal can deliver `ctrl+shift+enter`. A foreground command that can be kept inserts `ctrl+g backgrounds` immediately before the stop clause. When a message of yours is already waiting for the answer to finish, the last clause becomes -`ctrl+c stops and drops`. On the very first frame of a session the conversation carries the note -`esc back · ctrl+c interrupts or quits · ? for help`. +`esc stops and drops`. On the very first frame of a session the conversation carries the note +`esc interrupts · ctrl+c quits · ? for help`. **Stopping it and saying something new at once.** `ctrl+shift+enter` does both in one key — -see "Interrupt and say something new in one key" above. `ctrl+c` on its own stops without +see "Interrupt and say something new in one key" above. `esc` on its own stops without sending anything you have not already committed with `enter`. -**Escape is back, not stop.** It dismisses the nearest layer and eventually reaches -Home, preserving drafts and running work. Ctrl+C at rest quits the application. +**Limits.** Interrupting does nothing at all when no turn is running. `esc` reaches +the interrupt last: a history recall is cancelled first, rewind is armed on the way +past, and any open list or overlay takes the key before the message box sees it. So +`esc` while the command list or the `@` list is open closes that list and does +**not** interrupt. **Mid-turn `ctrl+c` only ever interrupts — that press never leaves.** It is spent on the model. The NEXT press is read at rest, and at rest `ctrl+c` is the way out — so the @@ -402,10 +399,11 @@ codeaf — how do I exit, close it, or why did ctrl+c not quit" below. ## Esc is not stopping it — how long does a stop take, why the turn is still finishing, how long stopping takes, and what happens if it will not let go -**Escape no longer stops work.** Use `ctrl+c` to interrupt. After Ctrl+C, the turn -may need a few seconds to let go; codeaf stops waiting after ten seconds. +**I pressed escape and it is still running.** That is this section: escape is not being +ignored, the turn is being let go of, and if it will not let go codeaf ends it for you +after ten seconds. -`ctrl+c` cancels the turn on the keystroke, but the turn does not close on the keystroke. A +`esc` cancels the turn on the keystroke, but the turn does not close on the keystroke. A `bash` call whose command left something holding its output waits up to three seconds before the pipes are forced shut, and a `jobs` kill spends two seconds on a polite signal and two more on the one that is not polite. For those seconds the status line reads @@ -427,8 +425,10 @@ becomes a row. Two things do still land, because neither can draw anything new: that was **already** on screen reports its own result if it returns in that moment, and what the turn spent is still counted. -**No key makes it stop harder.** The first Ctrl+C starts the ten-second window; -codeaf stops waiting when it expires. Another Ctrl+C at rest quits. Escape goes back. +**No key makes it stop harder, because the second stage is a clock and not a key.** A +second `esc` inside half a second is the rewind's door and `ctrl+c` at rest is the way +out, so neither is free — and you do not need one. The `esc` you already pressed started the +10-second window, and when it runs out codeaf stops waiting on its own. **What happens at 10 seconds.** codeaf detaches from the turn: the waits codeaf holds are ended and whatever request was still open to the model is aborted. A wait that ignores @@ -460,8 +460,8 @@ press to make, no window to beat, and nothing asking you to confirm it. writes your draft to disk and exits. **If `ctrl+c` did not quit, a turn was running.** Mid-turn that key is the interrupt — -the press is spent on the model. Escape only navigates back. Press it again once the -answer has stopped and codeaf exits. +the same thing `esc` does — and the press is spent on the model. Press it again once the +answer has stopped and codeaf leaves. **Nothing you typed is lost when you exit.** The unsent sentence in the box goes to disk, with any message that was still waiting for an answer folded in underneath it, and the @@ -485,8 +485,8 @@ one that does. The quit gesture is one `ctrl+c`, but where the press lands changes what it does. -**Mid-turn it is only the interrupt.** While an answer is streaming, `ctrl+c` -stops the turn, and that press does not leave. The next one, at rest, +**Mid-turn it is only the interrupt.** While an answer is streaming, `ctrl+c` is the same +key `esc` is: it stops the turn, and that press does not leave. The next one, at rest, does — so the two-tap people make mid-turn stops the model once and then quits. **It works over everything.** `ctrl+c` is read above every picker, panel, room, mode @@ -565,8 +565,8 @@ These apply with no overlay up, no room open, and no mode on. | `shift+enter` | Open a new line without sending, on home and in conversations | | `alt+enter` | Open a new line in a conversation | | `ctrl+j` | Same as `alt+enter` | -| `esc` | Back one layer, then Home; preserves message drafts and running work | -| `/rewind` | Opens the rewind timeline; repeated Escape never rewinds | +| `esc` | In order: cancel a history recall, then arm rewind, then interrupt the running turn — and send any message that was waiting for it | +| `esc` `esc` | Two presses inside a short window open the quick inline rewind mode. `/rewind` opens the full timeline instead | | `ctrl+c` | Turn running: interrupt, and nothing else. Nothing running: quit codeaf, on that press | | `ctrl+q` | Queue this message to run after the current turn. Empty box does nothing | | `ctrl+g` | A foreground command that can be kept: send that command to the background. Otherwise: close the task column, or bring it back. On a frame under 100 columns with no roster raised and no command to keep, it does nothing | @@ -918,7 +918,7 @@ your sent message too. It adds no characters and no cells; see "Slash commands a as chips" in the commands page for the whole of it. **A key chord is never given that background.** Where codeaf names a key — the hint slot -on the legend, the `/help` sheet, the opening `esc back · ctrl+c interrupts or quits · ? for +on the legend, the `/help` sheet, the opening `esc interrupts · ctrl+c quits · ? for help` — the chord is drawn one tier brighter than the words around it and nothing else changes. A tinted background always means a slash command and only ever that, so the two marks @@ -1066,7 +1066,7 @@ that has moved on. There is nothing to press; it is automatic. leave behind, and what `ctrl+enter` and `shift+enter` leave behind on a terminal that cannot send those chords, and nothing on the frame draws them. One kept on disk used to be adopted by the next window in the directory, which then opened with a box that - looked empty but still held invisible whitespace. + looked empty, was not, and refused `space space` for home. - The file is keyed by the directory plus this process's id, and is written with mode 0600. - At startup, if this window's own draft file is missing, codeaf takes the newest @@ -1900,9 +1900,9 @@ own line, so you can read it. **It does nothing on a machine with one conversation on it** — a first run, and nothing else — and says so by not being there: no card, and the keys row under the box does not -name it. Everywhere else that row reads `alt+e effort · alt+a approvals · alt+k chats · / commands · esc back` — `opt` in place of `alt` on a Mac. Effort and approvals appear only when the session +name it. Everywhere else that row reads `alt+e effort · alt+a approvals · alt+k chats · / commands · space space home` — `opt` in place of `alt` on a Mac. Effort and approvals appear only when the session has those controls. As the frame narrows, controls give way from the left, keeping -`/ commands · esc back`, then `/ commands` on its own. +`/ commands · space space home`, then `/ commands` on its own. **Taking a row is never refused for having too many open.** The card draws the first twelve rows and hands a digit to the first nine; past that the cursor is the way, and home is the @@ -2125,7 +2125,8 @@ to filter, `↑↓` to walk, `enter` to use it, `esc` to go back to the layer. ## Keys on home, and is there a shortcut for it -**Press `esc` to back out one layer at a time until Home.** `/home` opens it too. +**Press the space bar twice with an empty message box.** That is the way back to home from +inside a conversation, and `/home` opens it too. **There is also a number: `alt+1` (`opt+1` on a Mac).** Home is the first of the four places on the tab bar — `home tasks spend settings` — and each answers to its position there, @@ -2158,15 +2159,51 @@ such a page the line under the box names only the way out** — `tab next place tasks, on standing orders and on memory alike: a foot that offered `enter` or `type to filter` over a body with no rows would be naming a key with nothing to act on. -Press `esc` to go back one layer: close a picker, leave an editor or room, or put a -question aside. With no layer left, Escape opens Home. Further presses stay on Home. -Message drafts, running turns and queued messages are preserved. Filters may clear first. -Escape never starts rewind or stops a turn. `ctrl+c` interrupts a running turn and quits -when idle; `/rewind` opens the rewind timeline. - -The double-space binding has been removed. Spaces type normally in message boxes. -`/home` and `alt+1` (`opt+1` on a Mac) also open Home. Open a conversation row or use -`alt+k` to return to a conversation; Escape does not leave Home. +There is no `ctrl+` chord for home: every one this surface could use is already +taken, and `ctrl+.` is the tasks place (`/history`) from a conversation — while a place is +standing that same `ctrl+.` draws the map, on the terminals that can send it, because a place +takes the whole frame and never reaches the conversation's keys. `esc` was not available either: on an idle conversation it +already arms rewind and already clears messages waiting from the turn, and a third +meaning on one key in that state is how a surface stops being predictable. + +**The first space types itself.** The second one, finding a box that still shows nothing +with that space behind the cursor, takes the whole draft away and opens home — so a leading +space you actually wanted is never eaten (space then `x` leaves ` x`). It does nothing when +the box has words in it, and it is not a paste: text pasted with two leading spaces is two +spaces. A machine with one conversation, or none, opens an empty home; so does a session +over `--host`, where what opens is the **far machine's** home. + +**It answers from every place as well as from a conversation.** Wherever a place is +standing, the two spaces are read against that place's own filter — tasks, memory, search — +and open home just as they do from a draft; on spend and standing, which have nothing to +type into, two bare spaces open it and any key between them disarms it. On home itself the +door is a no-op: the page is already open, and two spaces type into home's own filter. It also does not answer from under a layer that owns the +keyboard: on the settings panel space is the drawn verb on a row (`activate`), +memory's card editor keeps every key while it is open, and inside a task's +record — the room the roster opens on `enter` — `space` pages the card the way +`pgdown` and `ctrl+f` do, so the door yields there and the key scrolls. Standing +cannot arm the door — its own keys never type into its box — but the box is the +shared composer, so a space left in it on another place still opens home from +standing. + +**A box that looks empty and is not still answers it.** Blank lines left by `ctrl+j`, +`alt+enter`, or by `ctrl+enter`/`shift+enter` on a terminal that cannot send those chords, +draw nothing on the frame — and the gesture reads the box the same way the frame does, so +two spaces open home and the blank lines go with the draft. The rule in one sentence: +wherever the foot advertises `space space home`, two spaces open it. + +It works while a turn is running; the answer keeps streaming underneath and `esc` puts you +back in it. + +When the box is empty, the keys row under the box says so: +`/ commands · space space home`, after any effort, approvals and chats hints. Clicking +`space space home` opens home; that clause vanishes as soon as you type. + +**The door does not ask what the machine holds.** It is open on a machine with only this +conversation and on one with none, from the first minute, and starting a second +conversation with `/new` changes nothing about it. It used to be shut until the launch +found somewhere else to go, and that rule is gone (the home page, *space space does +nothing*). Once it is open, **home is seven panels in one, two or three columns** (the home page has what each holds), and its keys are a small grammar: @@ -2179,12 +2216,12 @@ what each holds), and its keys are a small grammar: | `enter` | acts on the row under the cursor: a conversation opens, a project row starts a new chat in that folder, a `since you left` line opens its record, file or place, a `scheduled` row opens standing, a fold line opens or shuts its panel. `spend`'s lines are not stops, so the cursor never reaches them | | `pgup` / `pgdown` | jump a screenful | | `tab` | **the next place** on the bar | -| `esc` | dismisses a local layer; otherwise stays on Home and preserves the draft | +| `esc` | clears the box if anything is in it, and closes home otherwise | | `alt+.` | the map | | `backspace`, `ctrl+u`, `ctrl+w`, `ctrl+b`, `ctrl+f` | edit the box | | anything else | goes into the box, which searches the whole machine and offers to start a new conversation at the same time | -**Opening home puts the cursor on the chat you were in before this one**, so `esc` +**Opening home puts the cursor on the chat you were in before this one**, so `space` `space` then `enter` is a switch back; a window with only one conversation opens on its own row, which says `here`. The panel holding the cursor marks its heading with the cursor's ground, which is how you tell which column your arrows are in. **`alt+g` and `alt+q` are unbound on @@ -2256,21 +2293,24 @@ With all controls available the resting foot is `alt+p project · alt+e effort · alt+a approvals · alt+k chats · / commands`. The project and approvals hints are absent where those controls cannot act. `ctrl+o` still opens the selected row's folder and `tab` still moves to the next place, but neither -has a hint in home's bottom row. `esc` stays on Home; `alt+.` draws the whole map. +has a hint in home's bottom row. `esc` still closes home; `alt+.` draws the whole map. -Every ordinary grid row keeps the same list keys as the cursor walks. A fold names -its own keys, with the available draft controls before `esc`. Submission modes add no -footer hints. Plain text followed by Enter starts a new conversation; `/ask ` -asks in a home pane. +Every ordinary grid row keeps the same list keys as the cursor walks. A fold or action +row names its own keys, with the available draft controls before `esc`. For example: +`enter starts a new conversation and sends this · ↑ ask here · ↑↑ pick a match · alt+p project · alt+e effort · alt+a approvals · esc clear`. +Typing a slash command changes the first clause to `enter runs this command`. -**With nothing typed home is the panels. While typing, only search results appear above -the seam.** The best match is nearest the box. One `↑` selects it; `↓` past the last -result returns to composing. Clearing the box restores the panels. On wider frames, -the card beside the results follows the selected match. +**With nothing typed home is the panels**, hanging from the top. **While anything is typed +it is one list, a drop-up**: the action row — `start a new conversation: "…"` — is the LAST +row of the list, with `ask here: "…"` directly above it, both directly above the box, and +the matches rise above the pair **best one first**; the cursor starts on the action row, so +one `↑` reaches `ask here` and a second lands on the strongest match. Clearing the box puts +the panels back. On a frame 136 columns or wider a card about the match under the cursor +stands to the right of the list while you type; at rest there is no card. **On an `ask here` row** — the `?` rows an errand leaves at the end of the conversation list — the line under the box reads -`↑↓ move · enter or tab answer this ask here`. `enter` or `tab` hands the +`↑↓ move · enter or tab answer this ask here · esc close`. `enter` or `tab` hands the keyboard to the exchange's pane, where it reads `enter sends a follow-up · tab or esc back to the list`; `esc` or `tab` hands it back (*Asking from home*). @@ -2449,10 +2489,12 @@ words is news and the elbow's position is the whole of the record. They used to as fresh questions with a `›`, which made yesterday's correction read as a second instruction and made the page count turns nobody opened. -**`esc` in a room never interrupts and never stops.** Inside a room the first `esc` leaves the room and the next opens Home. Ending the task itself is `x` and its card. The legend's left end always +**`esc` in a room never interrupts and never stops.** Out in the conversation `esc` +interrupts the running turn; inside a room the first `esc` leaves the room and the next +one interrupts. Ending the task itself is `x` and its card. The legend's left end always names what the next `esc` does: `room · esc/←← main`, and `room · esc your line back` while a history walk is on. The keys row under the box reads `x stop` while there -is work here to stop and `↑↓ history` during a walk — it never reads `ctrl+c interrupt` +is work here to stop and `↑↓ history` during a walk — it never reads `esc interrupt` inside a room, because in here that is not what the key does. **A click inside the room's page does not leave it.** A press that lands on nothing — @@ -3058,7 +3100,7 @@ trailing marks, and the turn carries straight on. Read it as "go on" — let the command run and get on with the work. This is the key for the moment you realise `go test ./...` is going to take nine -minutes. The alternatives are `ctrl+c`, which stops the turn and throws the run +minutes. The alternatives are `esc`, which stops the turn and throws the run away, and waiting. Afterwards it is an ordinary job: ask codeaf to list them, tail one, or kill one, @@ -3082,7 +3124,7 @@ been running longest**, which is the one you are waiting on. While a command can be kept, this meaning takes precedence over hiding or restoring the task column, so the column stays where it was. With no such command the key belongs to -the column as described above. `ctrl+b` is copy mode and `ctrl+c` interrupts; neither changes. +the column as described above. `ctrl+b` is copy mode and `esc` interrupts; neither changes. ## When a settings change lands @@ -3280,7 +3322,7 @@ live-applies on the next render; work stays indented in either mode. `/resume`, `/permissions` and the rest do: the commands page. - **The status line, the legend under the box, and the layout**: the screen page. - **Tasks, rooms, proposals and the roster**: the tasks pages. -- **Rewind**, which `/rewind` opens: the sessions and +- **Rewind**, which `esc` `esc` opens quick and `/rewind` opens whole: the sessions and rewind page. ## Starting a new chat with plus diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index bc48c916f2..ad3f82680f 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -15,7 +15,7 @@ before each further try. A response proves endpoint reachability, not that every internet service is healthy. No separate public ping service is involved. Connection recovery waits up to two minutes, or less if that call already had -a shorter deadline. Ctrl+C or Stop work cancels your call immediately; other calls +a shorter deadline. Esc or Stop work cancels your call immediately; other calls still waiting keep their shared check. If the connection does not return, codeaf says `connection is still unavailable; try again when connected`. diff --git a/internal/manual/chat/places.md b/internal/manual/chat/places.md index bc61feaaf2..328ee5b15e 100644 --- a/internal/manual/chat/places.md +++ b/internal/manual/chat/places.md @@ -27,9 +27,8 @@ Every place is drawn in the same frame: 5. a rule, then the **composer** — one line you can type into, wherever you are 6. the hint line — what the keys do here -`esc` dismisses an editor or filter first, then returns to Home. The resting tasks, -spend and settings footers say `esc home`, including compact task screens. Places are not -stacked: opening one closes whichever was up. Further Escape presses stay on Home. +`esc` leaves a place and puts you back in the conversation you were in. Places are not +stacked: opening one closes whichever was up, so `esc` is always one press from the chat. ## How to get to a place — the keyboard shortcut to jump between pages @@ -55,7 +54,8 @@ Four ways, and they all reach the same seven rooms: conversations that match. A place ranks first, wears `▸`, and says `a place` out at the right margin. Home's list is a **drop-up** — it is read upward, out of the box you typed into — so ranking first means the offered place sits **below every conversation the same - words matched**, nearest the message box. One `↑` selects it. + words matched**, one row above `ask here` and `start a new conversation`, which is the + nearest row to your hand. Where the place can say what is behind it without going to the disk for it, the margin says that too: `a place · 6 orders, 1 fired today` on standing. A place that has nothing to count, or nothing in it, says `a place` alone. @@ -188,8 +188,9 @@ mark. On **search** the words you type are the query, drawn on the first row of same way, and `esc` clears them. On **memory** the head row echoes the filter in place of `type to filter`. Spend and standing take no text. -**Escape backs out to Home from every place.** Filters clear first where present; -editors and nested views close before their parent page. Two spaces no longer navigate. +**Two spaces still open home from every place.** On a place with a filter they are typed +into the empty filter and taken back out; on spend and standing, which have nothing to type +into, the two bare spaces are counted, and any other key between them disarms the door. ## The rule above home's box — where it lands, the model, thinking, approvals, what happened to the here ~/codeaf chip @@ -594,7 +595,7 @@ one word most people guess for "what has this cost" printed one conversation's b never mentioned the machine-wide ledger. It opens the place now. **The foot names the keys this place has**, and it is built from the row under the cursor: -`enter opens what spent it · → the limits · shift+←→ move the days · tab next place · esc home`. +`enter opens what spent it · → the limits · shift+←→ move the days · tab next place · esc close`. Where the head row is too narrow to draw its own arrows the window clause is dropped, and over an empty ledger only the way out is named. @@ -796,7 +797,7 @@ afternoon. place segment and the legend under the box already carry. On a local session it is not there at all: a machine name is worth a word only when there is more than one machine in play. -## Why is home empty over ssh when I connect to another machine — Escape over --host +## Why is home empty over ssh when I connect to another machine — space space over --host **It is not empty any more, and this is the answer if you have seen it be.** @@ -808,7 +809,7 @@ the chat you came from keeps running, the same door `codeaf resume` uses locally It used to draw **one dim line** where the rows would be — `home shows this machine's projects, and this session is on another` — because the projects it could reach were the laptop's while the work was on the server. Before that it refused to -open at all. If you press Escape over a connection and get one line, the machine you are +open at all. If you press space space over a connection and get one line, the machine you are attached to is running an older codeaf than the one you are sitting at, and the fix is the same as for any version mismatch: update the older one. diff --git a/internal/manual/chat/screen.md b/internal/manual/chat/screen.md index e161e1cac4..56c12d8458 100644 --- a/internal/manual/chat/screen.md +++ b/internal/manual/chat/screen.md @@ -182,7 +182,8 @@ rule above the message box remain. Below 6 rows those give way too, leaving the the box, and the status line. Blank rows cannot activate the content beneath them. **Home at the left opens the home page**, keeping your conversation and unsent words. -It is separate from the tabs and breadcrumbs. Escape backs out to Home. Home disappears when the connection cannot open conversations, and on +It is separate from the tabs and breadcrumbs. Space twice on an empty composer still +opens Home. Home disappears when the connection cannot open conversations, and on very narrow frames the current tab takes priority. The switcher floats on a separate background inside a rounded outline, with space @@ -689,8 +690,8 @@ and it came off because a title takes the room the numbers need: the name is on strip at the top of the frame and on the breadcrumb bar, and nowhere else. Nothing stands in for it — an unnamed conversation draws the same line. -Until 2026-09-17 the right end carried the keys that work now (`esc back · / -commands`, `ctrl+c interrupt`); those are on the row under the box now — see *The keys row +Until 2026-09-17 the right end carried the keys that work now (`space space home · / +commands`, `esc interrupt`); those are on the row under the box now — see *The keys row under the box* below — and the numbers came up here from the last row of the frame, so that home and a conversation end in the same shape: a rule of facts, the box, a line of keys. @@ -761,15 +762,15 @@ long title could never push the numbers off the frame, and the name moved off ag 2026-09-17 it was the right end of the rule above the box; the numbers took that end and the keys got a row of their own. It names the keys that work right now when a state has keys of its own — for example `y allow · n deny · a always` while a question is up, -`ctrl+c interrupt` while a turn is running, -`enter steers it in · ctrl+shift+enter stops and sends · ctrl+c interrupt` while a turn is +`esc interrupt` while a turn is running, +`enter steers it in · ctrl+shift+enter stops and sends · esc interrupt` while a turn is running and you have typed words on a terminal that can deliver the secondary key, -`enter waits · ctrl+shift+enter stops and sends · ctrl+c interrupt` while an otherwise empty +`enter waits · ctrl+shift+enter stops and sends · esc interrupt` while an otherwise empty box has a picture on its tray on that terminal, -`enter steers it in · ctrl+shift+enter stops and sends · ctrl+g backgrounds · ctrl+c interrupt` +`enter steers it in · ctrl+shift+enter stops and sends · ctrl+g backgrounds · esc interrupt` when that turn also has a foreground command that can be kept, or `↑↓ · enter · esc` while a list is open. A waiting message changes the final clause to -`ctrl+c stops and drops`; with neither words nor a picture the send clauses are absent. +`esc stops and drops`; with neither words nor a picture the send clauses are absent. **A question that cannot remember its answer loses the `a always` clause**, on this line and on the offer above it: a stuck turn is asked about with a scope codeaf cannot save, so the @@ -789,7 +790,7 @@ at least the first fitting clause remains, and a running turn never loses the ro because every clause would not fit. The row is the keys' own: nothing on the frame competes with them for it. -**The key itself is drawn apart from the word beside it.** In `ctrl+c interrupt`, `esc` +**The key itself is drawn apart from the word beside it.** In `esc interrupt`, `esc` wears the soft cyan every highlighted fact wears and `interrupt` stays at the border's own dim — the thing you press reads at a glance and the explanation of it does not compete. It is the same in every hint the slot carries, in home's foot hint, in the verbs @@ -799,21 +800,20 @@ below. **At rest it names the shared controls in home's order**, followed by the way home: ``` -alt+e effort · alt+a approvals · alt+k chats · / commands · esc home +alt+e effort · alt+a approvals · alt+k chats · / commands · space space home ``` On a Mac the modifier reads `opt`. Effort and approvals appear only when the session has those controls, and chats appears when there is another conversation to switch to. `tab` still returns to the last conversation but has no hint here. On narrow frames, -clauses give way from the left until `/ commands · esc home` remains, then +clauses give way from the left until `/ commands · space space home` remains, then `/ commands` alone if needed. -The conversation footer says `esc home`; a task conversation says `esc main`. Clicking -that hint takes the same route as Escape. Nested menus still close one layer first. -The hint remains available with a draft. `/` opens the command list. +Pressing the space bar twice on an empty box opens home; clicking `space space home` +does the same. That clause disappears when you type. `/` opens the command list. A state with its own keys, or an earned tip, takes over this row while it applies. -**Inside a task's room the slot is the room's**, and it never says `ctrl+c interrupt` there +**Inside a task's room the slot is the room's**, and it never says `esc interrupt` there — in a room `esc` leaves the page rather than interrupting anything. It reads `x stop` while there is work here to stop, `↑↓ history` while a history walk is on, and nothing otherwise. The left end of that legend is the room too: `room · esc/←← main`, or @@ -866,7 +866,7 @@ What steps up, in the lines you will see it in: | the legend's hint slot | the key, never the verb beside it | | `/help` | the key at the head of each row, never its explanation | | `/status` and `/cost` | the figure in the second column, never its label | -| the opening `esc back · ctrl+c interrupts or quits · ? for help` | the three keys | +| the opening `esc interrupts · ctrl+c quits · ? for help` | the three keys | Three rules hold it to one gesture, and they are worth knowing because they tell you what a mark means: @@ -1220,7 +1220,7 @@ words: | `working` | your own turn is over but work it handed out is still running — a task node in this conversation, or a background job; no spinner and no clock, which belong to a turn that is not running | accent | | `starting task` | a task proposal has a countdown and will start automatically | accent | | `waiting · your call` | an approval, standing or saved-program question requires an answer, or a task proposal has no countdown | the question hue, bold | -| `stopping · detaching in 7s` | you pressed `ctrl+c` and the turn has not finished letting go yet; the count is what is left of the 10-second bound before codeaf detaches | dim | +| `stopping · detaching in 7s` | you pressed `esc` and the turn has not finished letting go yet; the count is what is left of the 10-second bound before codeaf detaches | dim | | `interrupted` | the last turn was stopped by hand and is over | the bad hue | | `COPY` or `COPY · 12 lines` | copy mode | accent | @@ -1238,7 +1238,7 @@ against anything else. In the screen-reader tier the spinner is a still `*`. ## What the word stopping means in the status line, and why it is not interrupted yet -Because it has not finished stopping. `ctrl+c` cancels the turn instantly, but the turn does +Because it has not finished stopping. `esc` cancels the turn instantly, but the turn does not close instantly: a `bash` call whose command left something holding its output waits up to three seconds before the pipes are forced shut, and a `jobs` kill spends two seconds on a polite signal and two more on the one that is not polite. For those few seconds the @@ -1250,8 +1250,11 @@ nothing new is drawn — a reply the model was still speaking and a call it was through asking for both stop where they were rather than landing under the `interrupted` line. The word becomes `interrupted` the moment the turn is actually over. -**The second stop is a clock, not a key.** The window is bounded at 10 seconds from -the Ctrl+C you already pressed. Escape remains back navigation. The status line counts it down — `stopping · detaching in 7s` — +**The second stop is a clock, not a key.** There is no key to press, and there does not +need to be: `esc` again is the rewind's door (see the sessions and rewind page) and +`ctrl+c` at rest is the door, so neither is free, and a stop you have to ask for twice is a +stop that did not work the first time. So the window is bounded at 10 seconds from the +key you already pressed. The status line counts it down — `stopping · detaching in 7s` — and at the bound codeaf detaches: the waits inside the tool are ended, whatever request was still in flight is aborted, the conversation reads `detached — the turn was let go of and nothing is waiting for it`, and the turn is written to the journal as abandoned with @@ -1344,7 +1347,7 @@ Under 60 columns, eight things change shape: `enter`, or a **tap**, opens that row's card as a full-frame sheet whose top row reads `‹ back`; the card's answer chips become full-width answer bands, one per row, that a digit or a tap answers. The hint line under the box becomes one row of - at most three wide targets: `open` on the inbox, `‹ back · open · + at most three wide targets: `open · new · ask here` on the inbox, `‹ back · open · more` on a sheet. A tap **opens** — there is no second column to preview into, so there is no two-step — and mouse motion is ignored. Below width **24** the plain hint line is drawn instead of the bar. The rule over the box still says where the next @@ -1738,7 +1741,7 @@ glyph your messages wear in the conversation. Under it sits one dim line: ``` › do much more of a deep research please - waits for this answer · ctrl+c stops and drops · → steers it in · ↑ or click to edit + waits for this answer · esc stops and drops · → steers it in · ↑ or click to edit ``` The dim line trims from the right on a narrow terminal: the last piece goes first, then @@ -1749,7 +1752,8 @@ exactly one it is not counted at all. `→ steers it in` is there only while the message can go into the running answer: a turn still running, and a message of words alone. A waiting message that carries pictures, or one marked with `ctrl+enter`, cannot be sent in and the clause is absent for it. Pressing -`esc` opens Home and leaves the block with this conversation. `ctrl+c` stops the answer +`space` `space` opens Home and leaves the block with this conversation. `esc` or +`ctrl+c` stops the answer and removes the waiting block at once; `→ steers it in` is absent while a stopped turn is winding down because that turn has no boundary left to take the words. @@ -1769,7 +1773,7 @@ What happens to it: `a connection holds one conversation at a time`, switching ends the old conversation, so nothing can keep waiting on its answer. The waiting words return to the box and their pictures and pasted documents return to the tray after anything already there. -- **`ctrl+c`** stops the answer and drops every parked message and queued follow-up. None +- **`esc`** stops the answer and drops every parked message and queued follow-up. None starts a turn when the interrupted stream closes. - **`→` over an empty box**, or a **click on the words `→ steers it in`**, sends it **into** the running answer instead of leaving it to wait. A streaming generation @@ -1786,7 +1790,7 @@ What happens to it: dropped and codeaf says so: `1 waiting message dropped` or `N waiting messages dropped`. While something is waiting, the keys row under the box ends with -`ctrl+c stops and drops` instead of `ctrl+c interrupt`. +`esc stops and drops` instead of `esc interrupt`. ## Long lines inside a fence — code cut off at the edge, the tail of a line missing, `↳` @@ -3070,8 +3074,8 @@ What stands in their place is **one dim door** at the foot of the column: `ctrl+ **Everything earlier lives one press away.** `ctrl+. earlier` opens the full-screen task page (`ctrl+.`, `/history`), which holds every task the project has ever run, across every -session, with the filter, the cards and the mention. Home (`/home`, or Escape from the -conversation) is the other place old work is listed. Running work belonging to *other* +session, with the filter, the cards and the mention. Home (`/home`, or space twice on an +empty box) is the other place old work is listed. Running work belonging to *other* windows is not on the column at all, and never was; `/history` carries that too. The footer is up to three dim lines of counts — `3 running · 1 needs you`, `148 waiting · @@ -3526,7 +3530,7 @@ models produce a run of thought before the answer, on the same connection, bille way, and on a big conversation it can run for a minute before a word of answer appears. Nothing is wrong. The clock beside the word counts up, so a number that is moving is a program that is alive and painting; a clock that has **stopped** is the thing to worry -about. `ctrl+c` interrupts at any point. +about. `esc` interrupts at any point. `first word` is the other slow one, and it means something different: codeaf's request was accepted and the endpoint has written nothing at all — a queue, a cold model loading, or a @@ -3598,7 +3602,7 @@ thinking. A known phase is shown separately because it has better information. An advancing clock confirms the view is repainting. A still indicator alone does not prove a freeze: reduced-motion views use static marks, and narrow rows -can omit the clock. `ctrl+c` interrupts the turn. +can omit the clock. `esc` interrupts the turn. The waiting clock stops when the stream speaks or tools run. A tool uses its own activity and elapsed time. The request after a three-minute `go test` starts a diff --git a/internal/manual/chat/sessions-and-rewind.md b/internal/manual/chat/sessions-and-rewind.md index 40777ee384..854f48b14a 100644 --- a/internal/manual/chat/sessions-and-rewind.md +++ b/internal/manual/chat/sessions-and-rewind.md @@ -2,17 +2,38 @@ ## Taking a message back — rewind, how do I undo something I said -Use `/rewind` (aliases `/undo`, `/back`) to open a searchable timeline of the whole -conversation. Choose a point, press `enter` to place the pick, then `enter` again to -cut everything from that point onward. Escape clears the filter or closes the timeline. +Rewind cuts the conversation back to an earlier point and drops everything after it. Use it +when you phrased something badly and want to say it again better. -Repeated Escape is back navigation and never opens rewind. Rewind changes what the -model has been told; it does not undo files, commands or git changes. +**There are two tiers, and they answer two different questions.** + +| Way in | What you get | +| --- | --- | +| `esc`, then `esc` again within half a second | The **quick** inline mode: a cut line drawn through the transcript already on screen | +| `/rewind` (aliases `/undo`, `/back`) | The **rewind timeline**: the whole conversation as a fullscreen list, with a search and a preview | +| `tab`, from inside the inline mode | Lifts the inline mode into the timeline, carrying the cut you had already chosen | + +The first `esc` keeps its ordinary meaning — mid-turn it interrupts, at rest it does nothing +— and also arms rewind. The arming window is **500ms**. While it is warm, the hint slot says +exactly `esc again to rewind`. A stray `esc` after the window has lapsed changes nothing. +The command row for `/rewind` reads `go back to an earlier point · esc esc takes back the last`. + +Both tiers use the same `⟲` glyph, the same "drops N turns" arithmetic, the same cut, and +do the same things afterwards. Which one to reach for: `esc esc` for "not that, let me say +it again", `/rewind` for "take us back to before we started down this road". + +Rewind edits what the model has been told. It does not undo work that was done. Read the +section on what rewind does not undo before you rely on it. ## Seeing the whole conversation — the rewind timeline `/rewind` opens a fullscreen page listing the **whole** conversation, oldest first, with no -scrolling needed to get at the old end of it. The timeline reaches the whole conversation without loading older transcript blocks first. +scrolling needed to get at the old end of it. This is the one that can reach turns the +inline mode cannot: the inline mode picks out of the transcript **drawn on screen**, and a +conversation you came back to opens showing its last **40** blocks. Scrolling up pulls the +older ones in 40 at a time until you reach the first message (see the screen page), so the +inline mode reaches as far back as you have scrolled — and `/rewind` reaches the whole thing +without your having to. What is on it: @@ -64,9 +85,16 @@ not keep them, and codeaf will not invent them. ## Jumping to an old message from far back in the conversation -Open `/rewind`, type a word from the message, and press `enter` twice on the desired -point. The search reaches the entire conversation. If the cut is refused, the page stays -open with the reason in the foot. +Open `/rewind`, type a word you remember from the message, and press `enter` twice. That is +the whole route. The search reaches the entire conversation, not just the part drawn on +screen, which is why `esc esc` is the wrong tool for anything older than the last screenful. + +`tab` inside the inline mode does the same thing without retyping the command: it lifts you +onto the timeline with the cut you had already chosen, and your half-written draft comes +with you. The inline mode's own legend names the key. + +If the cut is refused, the page stays up with the engine's sentence in the foot, and the +same `enter` retries it a moment later. ## What rewind does NOT undo — your files stay changed @@ -132,9 +160,42 @@ the turn. The screen shows what was removed, so you can see this happen. ## Moving the cut line in the quick inline mode, and what the screen says -The double-Escape entry to inline rewind has been removed. Use `/rewind` to search the -whole conversation, preview a point and confirm the cut with two presses of `enter`. -Escape backs out without cutting anything. +This is the `esc` `esc` mode — the transcript on screen with a line drawn through it. The +keys are: + +| Key | What it does | +| --- | --- | +| `↑` / `↓` | Walk whole turns | +| `←` / `→` | Step through the points inside the current turn | +| `enter` | Commit the cut | +| `tab` | Lift into the rewind timeline, carrying this cut | +| `esc` | Leave with nothing changed | + +The mode bar prints exactly +`↑↓ turns · ←→ steps · enter rewind · tab the whole conversation · esc back`. + +The cut opens on the last thing you said — the newest turn point — or on the newest point of +any kind when there is no turn point at all. `←`/`→` are bounded by the current turn, so a +step walk cannot leave it. `↑`/`↓` never fall off either end. + +While the mode is up it takes **every** key. Nothing falls through to the draft box, because +the mode bar is standing where that box was. `ctrl+c` is read above it and stays the way +out — it does not leave rewind, it quits codeaf, with the mode still up. + +The mouse can do everything the keys can: click any transcript row to move the cut, click +the cut line itself to commit. A click chooses the nearest point at or above the row you +pointed at, so a click never drops less than the row you aimed at. A click above every point +takes the oldest one. Nothing here is pointer-only. + +**What you see:** the bar that replaced the draft box reads `⟲ drops 2 turns` — the glyph, +the word `drops`, and the count. A cut landing on a turn boundary is counted in turns; a cut +inside the newest turn takes no whole turn and is counted in steps instead. On a +screen-reader ("linear") palette the glyph is `<<`. On a frame too narrow for both, the key +legend goes and the count stays. + +The cut line itself is a horizontal rule drawn above the chosen block, labelled +`⟲ rewind here` (`<< rewind here` in linear). Everything from the line down is repainted +dim — accents, diff colours and all — because "all of this goes" is the true statement. ## What happens when you press enter on a rewind @@ -169,8 +230,12 @@ place to cut. `/rewind` will not raise an empty timeline. inline mode is already on, copy mode is on, a task room is open, the settings panel is open, or the task rail is full. The commands page lists them. -**Repeated Escape never enters rewind.** It dismisses the current layer and eventually -lands on Home, without stopping work or dropping drafts. Use `/rewind` explicitly. +**`esc` `esc` does nothing.** `esc` will not arm rewind when something else on the surface +holds the keyboard. That is: the settings sheet, the deck, the expand view, the model +picker, the resume roster, the connections panel, the command menu, the completion list, the +welcome box, copy mode, a recall walk, an open room, the rail hold, fullscreen rail, an +approval question, an awaited task proposal, an active guard, any pending connect ask, and a +pending harness offer. Close or answer that thing first. **The sentences a rewind can come back with:** @@ -392,7 +457,7 @@ lands you on the ordinary prompt. So the greeting is what a **first run** sees — the launch where home has nothing to say, because the only conversation on the machine is the one already on screen. That is the one case where the wordmark and the centred message box greet you; home itself is still a -`esc` away. On a profile with nothing configured yet, the once-only setup screen +`space space` away. On a profile with nothing configured yet, the once-only setup screen comes first (the getting-started page), and the greeting arrives the moment it closes. ## The "resumed" line at the top — what it says, and where the file path went diff --git a/internal/manual/chat/starting-codeaf.md b/internal/manual/chat/starting-codeaf.md index 2b323a93bc..6df039bd43 100644 --- a/internal/manual/chat/starting-codeaf.md +++ b/internal/manual/chat/starting-codeaf.md @@ -88,7 +88,7 @@ underneath it. `esc`, or `enter` on the row the cursor starts on, drops into tha conversation; everything after that is the chat exactly as it always was. Home stays out of the way when you name a conversation, on a `--once` or `--host` run, and on a machine whose only conversation is the one already open — though it is -still there to go to: `esc` from the conversation, or `/home`, opens it on that +still there to go to: `space` twice on an empty box, or `/home`, opens it on that machine too. The home page covers the whole of it. Run it in the directory you want it to work in. That directory is where it stands diff --git a/internal/manual/chat/staying-on-that-machine.md b/internal/manual/chat/staying-on-that-machine.md index f9bb2e38da..59e10ee3ac 100644 --- a/internal/manual/chat/staying-on-that-machine.md +++ b/internal/manual/chat/staying-on-that-machine.md @@ -245,7 +245,7 @@ you are looking at is the line above. **Nothing is lost and nothing is closed.** The window is still attached: replies arrive live, the transcript is complete, you can scroll it, copy out of it, answer a permission -card, press `ctrl+c` to interrupt, and walk to any other place with the usual keys. What you +card, press `esc` to interrupt, and walk to any other place with the usual keys. What you cannot do is send a message — and typing characters does nothing at all, because there is no box on the screen to put them in. @@ -268,7 +268,7 @@ There is no lock and nothing to release. Whoever pressed `enter` most recently h the window that lost it keeps its own draft, its own scroll position and the whole conversation. -**Walking away instead:** Escape backs out to Home, exactly as they do +**Walking away instead:** two spaces in an empty box still open home, exactly as they do when you are typing, so you can leave the conversation running in front of the other window and get on with something else on this machine. diff --git a/internal/manual/chat/subharnesses.md b/internal/manual/chat/subharnesses.md index cac279dde0..25ab12357d 100644 --- a/internal/manual/chat/subharnesses.md +++ b/internal/manual/chat/subharnesses.md @@ -174,16 +174,15 @@ this machine, and `/home`, sees this conversation as `waiting on you`, with the | `←` / `→` | walk the answers | | `enter` | take the answer under the cursor | | `1` | run it, from anywhere on the card | -| `0` | no — nothing runs | -| `esc` | defer; `/subharness` returns to this card | +| `0` or `esc` | no — nothing runs | | `↑` / `↓` | move between the fields and the answers | | `enter` on a field | open the box and type its value | -The hint under the answers reads `←→ · enter takes it · 0 no · esc back`. +The hint under the answers reads `←→ · enter takes it · 0 or esc, no`. -**`esc` defers the offer without answering.** `/subharness` reopens the same pending card -with your field edits intact. The note reads `subharness waiting · /subharness to return`. -`0` explicitly declines. Another Escape goes back toward Home. +**`esc` here is a no and not a way out.** A turn is waiting on this question, so the key +that dismisses every other overlay answers this one instead — nothing runs, and the +conversation carries on immediately. **If you never answer it, nothing runs.** The offer holds the turn for at most **15 minutes**; when that runs out the card comes down by itself, the line under the diff --git a/internal/manual/chat/tasks.md b/internal/manual/chat/tasks.md index bfbd8c8a7a..db5ca07878 100644 --- a/internal/manual/chat/tasks.md +++ b/internal/manual/chat/tasks.md @@ -2345,7 +2345,7 @@ another one's without the column ever saying it had. Everything they offered is other side of the door, whole: every row, the filter, the cards, and `m` for the mention. **Where old work is listed now:** the task page (`ctrl+.`, `/history`, or that line), and -home (`/home`, or Escape from the conversation). The chat can also read the whole project +home (`/home`, or space twice on an empty box). The chat can also read the whole project record for you with its `tasks` tool — just ask. **Running work in another codeaf window** is on no surface but the task page. An ordinary @@ -3319,13 +3319,13 @@ local conversation the same page tails that log live. | clicking empty space | nothing | nothing — leaving is `esc`, `←`, or the pinned header | | what `enter` does | sends to the model, or holds the message above the box while a turn is running | **steers the task** — never held | | what `↑`/`↓` do | walk your history, then select a tool row, then scroll | the same walk through **the same history** — steered lines are in it — then scroll the page | -| what `esc` does | backs out to Home, preserving work | leaves the room. It never interrupts and never stops work | +| what `esc` does | interrupts the running turn | leaves the room. It never interrupts and never stops work | | how you stop the work | `esc` | `x` over an empty box, which raises the confirmation card | | the box's own line | the bare `› ` | a tinted segment naming the task, in its state's hue, then `› ` | | box placeholder | the draft prompt | `Steer this task… (esc: main)`, or `Steer … (esc: main)` where the frame is too narrow for the segment | | pinned top rows | the pulse line, the tab strip under it, one thin rule and a blank — the same four rows every place draws; a dim `+N` at the strip's right end counts the tabs it could not spell, and `alt+k` opens the chats card | the same four rows — pulse, tab strip, rule, blank — so the rule does not move when you walk in; then a breadcrumb row (conversation → ancestor tasks → current task) and a quiet facts row under it | | legend word | the model, effort and approvals, with the remote machine when connected | `room · esc/←← main`, and `room · esc your line back` while a history walk is on | -| legend hint | `ctrl+c interrupt` while a turn runs | `x stop` while there is work to stop, `↑↓ history` mid-walk, nothing otherwise | +| legend hint | `esc interrupt` while a turn runs | `x stop` while there is work to stop, `↑↓ history` mid-walk, nothing otherwise | | the model on the status row | the conversation's model | `task <the task's model>` | | clicking that model | opens the picker and switches the conversation | opens the picker and switches **that task**, from its next request — and does nothing at all once the task has landed | | `ctrl+b` | freezes the transcript | freezes the room's own rows | @@ -5320,7 +5320,7 @@ changes with the cursor: The final clauses describe the **page** rather than the row: -- `esc home` returns to Home when the filter is clear. +- `esc close` returns to the conversation when the filter is clear. - `type to filter`, because nothing else on the frame says that a letter goes into the box on the control row rather than to the page's own keys. While a filter **is** on, that slot says `esc clear the filter` instead — the one fact the box itself cannot show is that esc diff --git a/internal/manual/chat/what-i-can-do.md b/internal/manual/chat/what-i-can-do.md index 50a80a8f91..ecbacea2f6 100644 --- a/internal/manual/chat/what-i-can-do.md +++ b/internal/manual/chat/what-i-can-do.md @@ -250,7 +250,7 @@ not. Two things it does **not** do: -- **A command you interrupted is interrupted.** Pressing `ctrl+c` cancels the turn, +- **A command you interrupted is interrupted.** Pressing `esc` cancels the turn, and a cancelled command is never kept as a job: it dies, no job appears, and the answer is `Command aborted`. Stop means stop. - **It does not outlive the conversation.** A foreground command kept as a job is a job, so it is @@ -845,7 +845,7 @@ absolute path**, however you spelled it in the call: **One look gets ten minutes**, and then the tool answers without it. A model that takes the picture and goes quiet used to leave the row running for the rest of the conversation; now the window runs out and you get the line above instead. -Press ctrl+c and the look stops on the same beat everything else does. +Press esc and the look stops on the same beat everything else does. **When no looking model can be reached, the tool is not there at all** — it is left off the toolbelt rather than offered and made to refuse. Ask for a picture diff --git a/internal/tui3/app.go b/internal/tui3/app.go index a935d2855f..b61bfc7f64 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -1868,6 +1868,10 @@ type app struct { // sends no status-line news (hostlink.go's [app.sayNewsSilence]). It is said // once per window, because it is a fact about a machine and not about a turn. newsSilenceSaid bool + // watchSpaces counts the run of spaces a WATCHER has typed, which is how the + // door home is reached from a register with no box on the frame + // (watching.go's [app.watchKey]). It is zero everywhere else. + watchSpaces int // linkLatency is the hosted connection's rolling round trip, and // linkPingAsking keeps its slow clock to one call at a time. Both are zero on // every local session and before the first hosted answer, which the @@ -1971,6 +1975,7 @@ type app struct { // so it is refreshed on the paint clock while something on screen is drawing // it and held between times. away elsewhereCache + // pilots are the watchers on the nodes that are running right now, keyed by // id, and pilotGen the counter each one takes its generation from (task.go). // Empty is the ordinary state: nothing is running, so nothing is watched. @@ -2190,6 +2195,10 @@ type app struct { // not now that a conversation draws it too (pulsebeat.go). Every figure in it // is read from the MACHINE, never from what a screen was holding (#525). machine machineFacts + // placeSpaceArmed is the first of the two spaces that open home from a + // place with no box — spend, standing — held until the second lands or any + // other key disarms it (placekeys.go's [app.placeHomeGesture]). + placeSpaceArmed bool // pageMsg is the one refusal a place that is not home has to say, drawn where // the hint would be. It is one field for [homeView.msg]'s reason: pressing a // door twice says the same thing once. @@ -2424,7 +2433,11 @@ type app struct { // can sit on, and the draft it is holding (rewind.go). Closed, it costs the // frame nothing. // - // rewSay and rewSayAt hold a temporary rewind refusal on the frame clock. + // escArm is when the first esc landed, or zero — the door's other half, which + // lives out here rather than inside the mode because it is a fact about the + // mode being DOWN. rewSay is one sentence the mode could not act on + // ("nothing to rewind") and rewSayAt when it was said; both run down on the + // frame clock ([app.rewindSweep]), because this surface has one clock. rew rewindMode // rewSheet is the DELIBERATE rewind: the whole conversation as a full-frame // timeline, with a search, a preview of the pick and a two-stage enter @@ -2432,6 +2445,7 @@ type app struct { // whole, and it is built from the session's own transcript rather than from // the drawn blocks — which is why it can reach turns the inline mode cannot. rewSheet rewindSheet + escArm time.Time rewSay string rewSayAt time.Time // tmux says this surface is inside a multiplexer, so a clipboard write has @@ -2684,7 +2698,7 @@ func (a *app) noteKilled() { // opens with, which is already about the keys nothing else names, is where it is // written down. It is the third and last clause because the two in front of it // are about the session a person is in and this one is about the program. -const landingKeysWord = "esc back · ctrl+c interrupts or quits · ? for help" +const landingKeysWord = "esc interrupts · ctrl+c quits · ? for help" func newApp(ctx context.Context, opts Options) *app { // THE ENVIRONMENT IS READ THROUGH THE SEAM AND NOWHERE ELSE, so the four @@ -2971,8 +2985,8 @@ func newApp(ctx context.Context, opts Options) *app { // IT HAS TO BE TRUE IN EVERY STATE, and the line it replaced was not: it // promised an interrupt on the first frame of a session where nothing was // running, and at that moment ctrl+c was the door rather than a stop. The - // clauses distinguish back navigation from the stop-or-quit key: Escape - // goes back, while Ctrl+C stops a running turn or leaves at rest (leaving.go). + // two clauses here are each true whatever is happening — esc stops the turn + // when there is one, and ctrl+c at rest always leaves (leaving.go). // // AND IT WAITS FOR THE GREETING TO GO. On an empty session the line lands // when the conversation begins rather than above a screen that is asking for @@ -4929,7 +4943,10 @@ func (a *app) paint() tea.Cmd { // update wakes this clock, and the clock keeps turning while the store // says the run is still out (homestanding.go, standing.go). a.standingAnimating() || - // Rewind refusals expire on the shared frame clock. + // AND THE REWIND ARM IS THE SEVENTH, and the only one of them that turns + // with nothing on screen moving at all: the hint slot says "esc again to + // rewind" for half a second, and something has to be drawing the frame + // that takes it away again (rewind.go). a.rewindTicking() || // AND A STOP BEING LET GO OF IS THE TENTH, and it is the third that turns // with nothing on screen moving at all — a stopped turn draws nothing new @@ -7177,8 +7194,6 @@ func (a *app) slash(line string) tea.Cmd { // the shape every choice row on this surface refuses in. return a.runEffort(rest) - case "ask": - return a.runAskCommand(rest) case "task": return a.runTaskCommand(rest) @@ -7278,7 +7293,7 @@ func (a *app) slash(line string) tea.Cmd { case "rewind": // THE COMMAND IS THE DELIBERATE DOOR AND IT OPENS THE TIMELINE - // (rewindsheet.go). Escape remains back navigation + // (rewindsheet.go), while esc esc keeps the quick inline gesture // (rewind.go). Somebody who typed six letters to get here has already told // this surface that the answer is not the message they just sent — it is // somewhere back in the conversation, and finding it wants the whole of the @@ -7460,7 +7475,7 @@ func (a *app) freshAndEmpty() bool { return false } // A NOTE IS NOT A CONVERSATION. Every surface opens with the surface's own - // lines on it — `esc back · ctrl+c interrupts or quits`, a door's notice, a + // lines on it — `esc interrupts · ctrl+c quits`, a door's notice, a // refusal somebody read — and counting those would make "fresh and empty" // false on the very first frame of every session, which is the one state // this test exists to recognise. @@ -7747,7 +7762,7 @@ func (a *app) quit() tea.Cmd { return tea.Quit } -// interrupt is ctrl+c: stop the turn, keep what it said. +// interrupt is esc: stop the turn, keep what it said. // // EVERYTHING A STOP OWES THE SCREEN IS PAID AT THE KEY, and that is the whole of // what this function changed when the interruption wave went through it. The @@ -7760,7 +7775,7 @@ func (a *app) quit() tea.Cmd { // disagreeing with the one fact the person is certain of — they pressed the key. func (a *app) interrupt() { a.interruptTurn() - // CTRL+C STOPS EVERYTHING, including both ways a later turn can already be + // ESC STOPS EVERYTHING, including both ways a later turn can already be // waiting. The session drops its follow-up queue on interrupt; the surface // drops that mirror and its editable parked queue in the same keypress so the // stream close cannot orphan or unexpectedly send either one. @@ -7770,7 +7785,7 @@ func (a *app) interrupt() { // interruptForBarge stops the current turn but preserves the draft // [app.bargeIn] just parked. ctrl+shift+enter promises stop-and-send; it shares the -// stop machinery with ctrl+c without sharing ctrl+c's queue-clearing decision. +// stop machinery with esc without sharing esc's queue-clearing decision. func (a *app) interruptForBarge() { a.interruptTurn() a.dropFollows() @@ -7832,7 +7847,7 @@ func (a *app) interruptTurn() { a.note("stopped") // AND THIS TURN PROMOTES NOTHING (hierarchy.go's [app.cutTurn]). The mark goes // on the blocks at the keypress so the demotion is on screen the moment the - // person presses ctrl+c, and again when the stream finally closes ([app.settle]), + // person presses esc, and again when the stream finally closes ([app.settle]), // because events in flight land between the two. a.cutTurn(a.turn) } @@ -7844,9 +7859,25 @@ func (a *app) interruptTurn() { // it are still true and the second one is what dictated how this was built. What // changed is that the thing it said did not exist now does. // -// The stop bound is a clock, not a second key: Ctrl+C already asked the turn -// to stop, and Escape must remain navigation. The deadline starts on that -// first stop and releases a turn that will not finish letting go. +// WHAT THE ARGUMENT GOT RIGHT, FIRST HALF: THERE IS NO KEY LEFT. esc's grammar +// in the conversation is read in a fixed order (input.go, rewind.go): a recall +// walk takes it, then [app.escRewind] — where the first esc ARMS the rewind on +// its way past and a second one inside [rewindArmWindow] OPENS it — and only +// then [app.interrupt]. So every esc that lands within half a second of another +// esc already belongs to rewind, and THE INTERRUPT IS NOT FOR SALE cuts the +// other way just as hard. Putting a hard stop AFTER the window does not save it +// either, because an esc past the window is a FIRST esc again, so the key would +// mean "stop harder" or "open the rewind" depending on what the person did half +// a second later — one keypress with two readings, which is the one thing this +// keyboard cannot have. ctrl+c is spoken for on both sides of the same moment: +// mid-turn it is the interrupt, and at rest — which is what winding down IS — +// it is the door (leaving.go). +// +// THAT REMAINS TRUE, SO THE SECOND STAGE TAKES NO KEY AT ALL. It is a CLOCK, +// started by the esc the person already pressed, and it needs no grammar because +// it asks for no gesture. A person who wants a turn to stop has said so once; +// making them say it twice, harder, into a surface that already heard them is +// the exact experience issue #265 was filed about. // // WHAT THE ARGUMENT GOT RIGHT, SECOND HALF, AND WHY IT DICTATED THE ORDER OF // WORK: A CAPABILITY THAT CANNOT WORK IS ABSENT, NOT BROKEN. A deadline whose diff --git a/internal/tui3/back_test.go b/internal/tui3/back_test.go deleted file mode 100644 index 041edd1e51..0000000000 --- a/internal/tui3/back_test.go +++ /dev/null @@ -1,224 +0,0 @@ -package tui3 - -import ( - tea "charm.land/bubbletea/v2" - "github.com/Agent-Field/codeaf/internal/session" - "testing" - "time" -) - -func backApp(t *testing.T) *app { - t.Helper() - lab := newHomeLab(t) - mine := lab.session("-alpha", "aaaa000000000001", "here", "/tmp/alpha", time.Now()) - return lab.app(mine) -} - -func TestEscapeReachesHomeAndPreservesRunningWorkAndDrafts(t *testing.T) { - a := backApp(t) - agent := &rewindFake{fakeAgent: &fakeAgent{model: "m", past: rewindPast()}} - a.agent = agent - a.state = stateWorking - a.parks = []parked{{text: "parked words"}} - a.follows = []queued{{text: "queued words"}} - a.input.setText("half a thought\nand another line") - for range 8 { - drive(t, a, key("esc")) - } - if !a.at(pageHome) || a.rew.on || a.rewSheet.open || agent.stops != 0 || len(agent.cuts) != 0 || a.state != stateWorking { - t.Fatal("Escape did more than navigate home") - } - if len(a.parks) != 1 || len(a.follows) != 1 { - t.Fatal("Escape dropped waiting messages") - } - if a.input.String() != "half a thought\nand another line" { - t.Fatal("lost conversation draft") - } - a.home.box.setText("a home draft") - for range 8 { - drive(t, a, key("esc")) - } - if !a.at(pageHome) || a.home.box.String() != "a home draft" { - t.Fatal("Escape left Home or lost its draft") - } - a.closeHome() - if a.input.String() != "half a thought\nand another line" { - t.Fatal("returning lost the draft") - } - drive(t, a, key("ctrl+c")) - if agent.stops != 1 { - t.Fatal("Ctrl+C no longer interrupts") - } -} - -func TestEscapeDismissesCommandListsWithoutLosingEitherDraft(t *testing.T) { - for _, home := range []bool{false, true} { - a := backApp(t) - if home { - a.openHome() - typeHome(a, "/mo") - } else { - a.input.setText("/mo") - a.syncLists() - } - drive(t, a, key("esc")) - if home { - if a.home.cmd.open || a.home.box.String() != "/mo" { - t.Fatal("Home command dismiss lost draft or kept list") - } - } else { - if a.menu.open || a.input.String() != "/mo" || a.at(pageHome) { - t.Fatal("conversation command dismiss skipped a layer") - } - } - for range 4 { - drive(t, a, key("esc")) - } - if !a.at(pageHome) { - t.Fatal("Escape did not settle on Home") - } - } -} - -func TestEscapeFromEveryPlaceSettlesOnHome(t *testing.T) { - for _, where := range []page{pageHome, pageTasks, pageSettings, pageMemory, pageSearch, pageSpend, pageStanding} { - t.Run(string(rune('0'+where)), func(t *testing.T) { - a, _ := driveToPlace(t, newHomeLab(t), where) - for range 12 { - drive(t, a, key("esc")) - } - if !a.at(pageHome) { - t.Fatalf("%v did not reach Home", where) - } - }) - } -} - -func TestDoubleSpaceNoLongerNavigates(t *testing.T) { - a := backApp(t) - for _, draft := range []string{"", "\n", "words"} { - a.input.setText(draft) - drive(t, a, key(" ")) - drive(t, a, key(" ")) - if a.at(pageHome) || a.input.String() != draft+" " { - t.Fatalf("spaces changed navigation or draft %q", draft) - } - } - for _, where := range []page{pageTasks, pageMemory, pageSearch, pageSpend, pageStanding} { - a, box := driveToPlace(t, newHomeLab(t), where) - drive(t, a, key(" ")) - drive(t, a, key(" ")) - if a.at(pageHome) { - t.Fatalf("spaces navigated from %v", where) - } - if box != nil && box.String() != " " { - t.Fatalf("spaces lost from %v", where) - } - } -} - -func TestEscapeDefersSubharnessUntilExplicitAnswer(t *testing.T) { - a := backApp(t) - a.subPage = subPage{open: true, card: &subCard{name: "pending", offer: 42}} - a.state = stateWorking - drive(t, a, key("esc")) - if a.subPage.open || !a.awaitingSubharness() { - t.Fatal("Escape answered or lost the offer") - } - for range 4 { - drive(t, a, key("esc")) - } - if !a.at(pageHome) || !a.awaitingSubharness() { - t.Fatal("pending offer prevented back navigation") - } - a.closeHome() - a.openSubharness("") - if !a.subPage.open || a.subPage.card.offer != 42 { - t.Fatal("/subharness did not resume offer") - } - a.withdrawSubharnessProposal(42, "pending") - if a.awaitingSubharness() { - t.Fatal("withdrawal left a deferred offer behind") - } -} - -func TestEscapePeelsModalLayersBeforeHome(t *testing.T) { - for _, test := range []struct { - name string - open func(*app) - showing func(*app) bool - }{ - {"model", func(a *app) { a.pick.open = true }, func(a *app) bool { return a.pick.open }}, - {"effort", func(a *app) { a.effPick.open = true }, func(a *app) bool { return a.effPick.open }}, - {"crew", func(a *app) { a.crewPick.open = true }, func(a *app) bool { return a.crewPick.open }}, - {"resume", func(a *app) { a.roster.open = true }, func(a *app) bool { return a.roster.open }}, - {"folder", func(a *app) { a.folder.open = true }, func(a *app) bool { return a.folder.open }}, - {"files", func(a *app) { a.shelf.open = true }, func(a *app) bool { return a.shelf.open }}, - {"connections", func(a *app) { a.connPanel.open = true }, func(a *app) bool { return a.connPanel.open }}, - {"harness", func(a *app) { a.harnPanel.open = true }, func(a *app) bool { return a.harnPanel.open }}, - {"permissions", func(a *app) { a.permPanel.open = true }, func(a *app) bool { return a.permPanel.open }}, - {"drafts", func(a *app) { a.draftPage.open = true }, func(a *app) bool { return a.draftPage.open }}, - {"subharness list", func(a *app) { a.subPage.open = true }, func(a *app) bool { return a.subPage.open }}, - } { - t.Run(test.name, func(t *testing.T) { - a := backApp(t) - a.input.setText("keep this draft") - test.open(a) - drive(t, a, key("esc")) - if test.showing(a) || a.at(pageHome) { - t.Fatal("Escape skipped the modal's parent") - } - for range 4 { - drive(t, a, key("esc")) - } - if !a.at(pageHome) || a.input.String() != "keep this draft" { - t.Fatal("Escape failed to settle on Home with the draft") - } - }) - } -} - -func TestEscapeFromTaskRoomReachesHomeWithoutStoppingTheTask(t *testing.T) { - a, agent, _ := roomApp(t) - door := backApp(t) - a.open, a.resume = door.open, door.resume - clickRail(t, a, 0) - a.input.setText("a task correction") - drive(t, a, key("esc")) - if a.roomOpen() { - t.Fatal("Escape stayed in room") - } - for range 4 { - drive(t, a, key("esc")) - } - if !a.at(pageHome) || agent.stops != 0 { - t.Fatal("Escape failed to go Home without stopping") - } - a.closeHome() - clickRail(t, a, 0) - if a.input.String() != "a task correction" { - t.Fatal("Escape lost the task draft") - } -} - -func TestBackPastAnApprovalKeepsItUnansweredAndReopenable(t *testing.T) { - agent, a := wired([]session.Event{ - toolBegin("edit", "edit main.go"), - consentEvent(3, "edit", "edit main.go", `tool "edit"`), - }) - door := backApp(t) - a.open, a.resume = door.open, door.resume - typeLine(t, a, "fix it") - settleAsk(a) - for range 5 { - drive(t, a, key("esc")) - } - if !a.at(pageHome) || !a.asking() || len(agent.answers) != 0 || agent.stops != 0 { - t.Fatal("back navigation answered or stopped the pending question") - } - a.closeHome() - drive(t, a, tea.KeyPressMsg{Code: 'y', Mod: tea.ModAlt}) - if !a.questioning() { - t.Fatal("deferred question could not be reopened") - } -} diff --git a/internal/tui3/background.go b/internal/tui3/background.go index f15f7ef0de..d8b162180e 100644 --- a/internal/tui3/background.go +++ b/internal/tui3/background.go @@ -4,7 +4,7 @@ package tui3 // // Watching `go test ./...` grind through minute three used to leave a person // two choices, and both of them threw the work away: keep watching until the -// harness's own timeout killed it, or press ctrl+c, which killed it sooner. There +// harness's own timeout killed it, or press esc, which killed it sooner. There // was no third answer, because there was nothing under the surface that could // take a running process and keep it. // @@ -17,7 +17,7 @@ package tui3 // // ── THE KEY, AND WHY THIS ONE ── // -// ctrl+b is copy mode and ctrl+c is the interrupt, and neither is for sale. ctrl+g +// ctrl+b is copy mode and esc is the interrupt, and neither is for sale. ctrl+g // already closes and restores the task column; while a foreground command can // be kept, this reading wins, and with none the column keeps the key. It is // plain BEL so every terminal on every platform delivers it, and it needs no diff --git a/internal/tui3/background_test.go b/internal/tui3/background_test.go index bc3cb7afe6..ca02b3c8e7 100644 --- a/internal/tui3/background_test.go +++ b/internal/tui3/background_test.go @@ -73,7 +73,7 @@ func TestARunningCommandIsSentToTheBackgroundWithOneKey(t *testing.T) { drive(t, a, tea.KeyboardEnhancementsMsg{Flags: 1}) typeInto(t, a, "use the race-safe helper") wantHint := "enter " + steerSendWord + " · " + bargeKey + " " + bargeSendWord + - " · ctrl+g backgrounds · ctrl+c interrupt" + " · ctrl+g backgrounds · esc interrupt" if got := a.hintWord(); got != wantHint { t.Fatalf("the full running-turn hint is %q, want %q", got, wantHint) } diff --git a/internal/tui3/bargein.go b/internal/tui3/bargein.go index 87a4ee8fe9..2abea40fe0 100644 --- a/internal/tui3/bargein.go +++ b/internal/tui3/bargein.go @@ -83,7 +83,7 @@ import ( const bargeKey = "ctrl+shift+enter" // bargeSendWord is what this gesture does in the running-turn hint. It belongs -// to ctrl+shift+enter alone: ctrl+c stops and clears waiting queues, while this chord +// to ctrl+shift+enter alone: esc stops and clears waiting queues, while this chord // deliberately preserves the draft it just parked so the stream close sends it. const bargeSendWord = "stops and sends" diff --git a/internal/tui3/bargein_test.go b/internal/tui3/bargein_test.go index c5e8ba4599..4604585458 100644 --- a/internal/tui3/bargein_test.go +++ b/internal/tui3/bargein_test.go @@ -183,7 +183,7 @@ func TestTheChordIsAbsentOnATerminalThatCannotSpellIt(t *testing.T) { if a.bargeOffered() { t.Fatal("the chord is offered on a terminal that never said it could send it") } - if got := a.hintWord(); got != steerShortHint+" · ctrl+c interrupt" { + if got := a.hintWord(); got != steerShortHint+" · esc interrupt" { t.Fatalf("hint = %q, want the plain-enter steer where the chord cannot work", got) } if strings.Contains(plain(frame(a)), bargeKey) { @@ -213,7 +213,7 @@ func TestTheHintTeachesBothMeaningsOnlyWhileThereIsSomethingToSend(t *testing.T) // A running turn with an EMPTY box: nothing to send, so the slot keeps the // plain interrupt. This is the emptiness law on the line itself. - if got := a.hintWord(); got != "ctrl+c interrupt" { + if got := a.hintWord(); got != "esc interrupt" { t.Fatalf("an empty box while working = %q, want the plain interrupt", got) } diff --git a/internal/tui3/boxseam_test.go b/internal/tui3/boxseam_test.go index d6d11dc8b9..5fd3223848 100644 --- a/internal/tui3/boxseam_test.go +++ b/internal/tui3/boxseam_test.go @@ -340,7 +340,7 @@ func TestConversationControlsMatchHomeAndKeepTheHomeDoor(t *testing.T) { a.chords.meta = chordMetaWord a.notices.enabled = false a.branch = "dev" - want := "opt+e effort · opt+a approvals · opt+k chats · / commands · esc home" + want := "opt+e effort · opt+a approvals · opt+k chats · / commands · space space home" if got := a.footHint(200); got != want { t.Fatalf("conversation controls = %q, want %q", got, want) } @@ -369,9 +369,7 @@ func TestConversationControlsMatchHomeAndKeepTheHomeDoor(t *testing.T) { if got := ansi.Cut(line, a.homeDoor.from, a.homeDoor.to); got != homeDoorWord { t.Fatalf("home hit target covers %q at %d columns: %q", got, width, line) } - cmd, took := a.homeDoorPress(a.homeDoor.from, row) - drive(t, a, runCmd(cmd)...) - if !took || !a.at(pageHome) { + if _, took := a.homeDoorPress(a.homeDoor.from, row); !took || !a.at(pageHome) { t.Fatalf("home hint did not open home at %d columns", width) } a.closeHome() diff --git a/internal/tui3/bundle_test.go b/internal/tui3/bundle_test.go index c2e6117657..a29487d9e3 100644 --- a/internal/tui3/bundle_test.go +++ b/internal/tui3/bundle_test.go @@ -2254,10 +2254,10 @@ func TestTheHintSlotFollowsTheStateAndIsEmptyAtRest(t *testing.T) { } a.state = stateWorking - if got := a.hintWord(); got != "ctrl+c interrupt" { + if got := a.hintWord(); got != "esc interrupt" { t.Fatalf("a working surface offered %q", got) } - if line := plain(a.hintRow(120)); !strings.Contains(line, "ctrl+c interrupt") || + if line := plain(a.hintRow(120)); !strings.Contains(line, "esc interrupt") || strings.Contains(line, microcopy) { t.Fatalf("the hint did not take the slot: %q", line) } @@ -3316,7 +3316,7 @@ func TestEscFoldsTheProposalRatherThanDecliningItOrTheTurn(t *testing.T) { t.Fatalf("esc answered the proposal: %+v", agent.answered) } if agent.stops != 0 { - t.Fatal("ctrl+c interrupted the turn") + t.Fatal("esc interrupted the turn") } if !a.awaitingTask() || a.questionCount() != 1 { t.Fatalf("esc closed the proposal: awaiting=%v open=%d", a.awaitingTask(), a.questionCount()) diff --git a/internal/tui3/chatnotes_test.go b/internal/tui3/chatnotes_test.go index 5c797f132b..e40e8b6de5 100644 --- a/internal/tui3/chatnotes_test.go +++ b/internal/tui3/chatnotes_test.go @@ -176,7 +176,7 @@ func TestASurfaceNoteNeverReadsAsTheModelsNextBullet(t *testing.T) { entry{kind: entryAssistant, settled: true, text: "Try these:\n\n- one\n- two\n- three"}, ) a.note("3 standing orders here — /standing") - a.note("esc back · ctrl+c interrupts or quits") + a.note("esc interrupts · ctrl+c quits") a.touch() var drawn []string @@ -190,7 +190,7 @@ func TestASurfaceNoteNeverReadsAsTheModelsNextBullet(t *testing.T) { bullet = i case strings.Contains(line, "3 standing orders here"): first = i - case strings.Contains(line, "ctrl+c interrupts"): + case strings.Contains(line, "esc interrupts"): second = i } } diff --git a/internal/tui3/commands.go b/internal/tui3/commands.go index ea9bd8aea6..f5919e6239 100644 --- a/internal/tui3/commands.go +++ b/internal/tui3/commands.go @@ -150,7 +150,7 @@ var commands = []command{ // pick a point out of (rewindsheet.go) — and then the gesture that takes the // last message back without opening anything (rewind.go). Two tiers, one row, // in the order a person meets them. - {name: "rewind", desc: "go back to an earlier point", alias: []string{"undo", "back"}}, + {name: "rewind", desc: "go back to an earlier point · esc esc takes back the last", alias: []string{"undo", "back"}}, // WHAT HAS ALREADY BEEN ANSWERED, and the way to take one back // (permissions.go). It BELONGS beside /settings and /connect — those two are // "what may this thing do" and "what may it reach", and this is "what has it @@ -276,7 +276,6 @@ var commands = []command{ {name: "effort", desc: "how hard this conversation thinks · the five rungs, and what each buys", alias: []string{"think", "thinking"}}, {name: "effort", args: "<rung>", desc: "…set it outright · " + effortKey + " walks it, or press it on the seam"}, - {name: "ask", args: "<question>", desc: "ask here on home", door: sendDoorAsk}, {name: "task", args: "<brief>", desc: "start work you can walk away from", door: sendDoorTask}, {name: "task", args: "solo <brief>", desc: "…with one worker, and no sizing call before it", door: sendDoorTask}, // THE THIRD ROW IS GONE, AND ITS ABSENCE IS THE FEATURE. It typed @@ -977,7 +976,7 @@ func helpText(file string, chords chordSpelling) string { // THE DOOR IS NAMED HERE BECAUSE ONE KEY CARRIES TWO MEANINGS // (leaving.go): at rest it leaves, mid-turn it stops the model, and a // person whose ctrl+c "only interrupted" looks here before anywhere else. - "ctrl+c quits everything · mid-turn it interrupts instead", + "ctrl+c quits everything · mid-turn it interrupts instead, like esc", // tab is the seventeenth rung of the key router (input.go) and does // nothing at all when this terminal holds one conversation — which is // why the line says what it needs rather than promising it always works. @@ -1081,7 +1080,7 @@ func helpText(file string, chords chordSpelling) string { // the machine, and one word meaning two places on the same list is a // person pressing ← ← to find out where they end up. "← ← out of a task room · the conversation, at the live edge", - "esc back one layer · home when no layer remains · /home", + "space space over an empty box: home · /home · esc back", // THE WORD KILL IS NAMED BY THE KEYS THAT STILL REACH THE BOX. ctrl+w was // on this row until it became the close-tab chord above, and a sheet that // went on offering it would be teaching a keystroke that shuts the window diff --git a/internal/tui3/escword_test.go b/internal/tui3/escword_test.go index 3c9d1fe488..cd9662c01b 100644 --- a/internal/tui3/escword_test.go +++ b/internal/tui3/escword_test.go @@ -52,7 +52,7 @@ func TestTheKeySheetSpellsTheEscapeGestureOneWay(t *testing.T) { {"the task roster", railHoldChord + " ", "esc back"}, {"the new chat", newChatChord + " ", "esc back"}, {"the conversation switcher", hopOpenKey + " ", "esc cancel"}, - {"back navigation", "esc back", "home when no layer remains"}, + {"space space, over an empty box", "space space", "esc back"}, } { found := "" for _, line := range strings.Split(sheet, "\n") { diff --git a/internal/tui3/foot.go b/internal/tui3/foot.go index 84f5272a5d..682fefb989 100644 --- a/internal/tui3/foot.go +++ b/internal/tui3/foot.go @@ -11,7 +11,7 @@ import ( // // ─ glm-5.3-flash (deepinfra):high · ◇ asks ── $0.27 · 58% cached 66.8k/1.3M · 5% ⠹ working · 12s project: ~/src/parser ─ // › your sentence -// alt+e effort · alt+a approvals · alt+k chats · / commands · esc back +// alt+e effort · alt+a approvals · alt+k chats · / commands · space space home // // THE SEAM IS WHAT ANSWERS AND HOW MUCH. The rule above the box carries the // model answering the conversation on the left, and the numbers on the right diff --git a/internal/tui3/footswap.go b/internal/tui3/footswap.go index fec3ba6306..096d2f4bc4 100644 --- a/internal/tui3/footswap.go +++ b/internal/tui3/footswap.go @@ -11,7 +11,7 @@ import ( // Until 2026-09-17 the seam over the box carried the keys that work right now // on its right, and the row under the box carried the numbers: // -// ─ porting the parser · glm-5.3-flash:high · ◇ asks ──── esc back · / commands ─ +// ─ porting the parser · glm-5.3-flash:high · ◇ asks ──── space space home · / commands ─ // › your sentence // $0.27 · ⟲ saved $0.0038 · 58% cached 66.8k/1.3M · 5% 38 tok/s · ⠹ working · 12s // @@ -22,7 +22,7 @@ import ( // // ─ glm-5.3-flash (deepinfra):high · ◇ asks ── $0.27 · 58% cached 66.8k/1.3M · 5% ⠹ working · 12s ─ // › your sentence -// alt+e effort · alt+a approvals · alt+k chats · / commands · esc back +// alt+e effort · alt+a approvals · alt+k chats · / commands · space space home // // ─ glm-5.3-flash:auto · ◇ asks ───────────────── project: ~/codeaf ─ // › type to search or start something new @@ -240,9 +240,9 @@ func (a *app) hintRow(width int) string { } else { hint = "" } - if offset := strings.Index(hint, a.escapeDoorWord()); offset >= 0 { + if offset := strings.Index(hint, homeDoorWord); offset >= 0 { from := 1 + ansi.StringWidth(hint[:offset]) - a.homeDoor = hudSpan{from: from, to: from + ansi.StringWidth(a.escapeDoorWord())} + a.homeDoor = hudSpan{from: from, to: from + ansi.StringWidth(homeDoorWord)} } // THE ROW FILLS THE FRAME, as every foot row does: a row shorter than the // frame would leave the cells behind it to whatever the last frame drew. diff --git a/internal/tui3/home.go b/internal/tui3/home.go index de8200e627..08c3b1e070 100644 --- a/internal/tui3/home.go +++ b/internal/tui3/home.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "sort" + "strconv" "strings" "time" @@ -355,7 +356,17 @@ const ( // under a dim rule — and everything under that rule is as reachable as // everything above it. homeElsewhereWord = "elsewhere" - // homeRunWord identifies command drafts for the dispatcher. + // homeStartWord is the action row's label, with what was typed quoted after + // it. "conversation" and not "chat" because that is what this surface calls + // one everywhere else it names one — /new closes a session and starts a + // fresh one, and the manual has said "conversation" since before home + // existed. + homeStartWord = "start a new conversation" + // homeRunWord is what that same row says instead when the box holds a + // COMMAND rather than a sentence. Enter dispatches a "/" line and never + // sends it ([app.homeEnter]), so a row still offering to start a + // conversation with it would be the one row on this screen that names the + // wrong key's meaning — see [homeView.runLabel]. homeRunWord = "run" // homeStartGlyph marks it. A plain `+` on purpose: it is the one row on the // column that is not a thing that exists yet, and every other glyph here is @@ -415,6 +426,27 @@ const ( // the same. A line that says work is being hidden and cannot be asked to // stop hiding it is a dead end somebody hits and gives up at. homeQuiet + // homeAction is "start a new conversation", drawn only while something is + // typed and always at the very BOTTOM of the list. It is a cursor stop and + // it is where the cursor RESTS by default, which is what keeps type-and-enter + // meaning exactly what it meant before the box could also search. + // + // IT USED TO LEAD THE LIST, AND THAT SPLIT A PERSON'S ATTENTION IN TWO. The + // characters appear in the box at the FOOT of the frame, and the row that + // says what enter will do with them stood at the TOP — so typing made the eye + // jump between the two far ends of the screen, and the cursor was up at one + // end while the caret blinked at the other. Everything about typing now + // clusters at the foot: the box, the row directly above it, and the hint line + // under it, with the matches growing UPWARD above them. It is the drop-up the + // command list and the "@" list already are (render.go's overlay), which is + // what this screen should have been from the start — a list that rises out of + // the thing you are typing into. + // + // IT IS ALSO THE ONLY THING THAT MOVES THE LIST. With nothing typed home is + // a dashboard hanging from the top of its region and this row does not exist; + // the first character brings it into being at the foot and lifts the list to + // meet it (the block above [homeView.buildWorld] states both halves). + homeAction // homeBlank is the empty line between projects. homeBlank // homeItem is ONE STANDING ITEM — a reminder, a watch, a rule, an overnight @@ -952,7 +984,7 @@ func (a *app) raiseHome() tea.Cmd { // // BEING GREETED BY HOME AND BEING ABLE TO GO THERE ARE TWO QUESTIONS, and // this condition answers only the first. The door from inside the -// conversation — `esc`, `/home`, the advertisement at the foot — is +// conversation — `space space`, `/home`, the advertisement at the foot — is // open on every machine home can read at all ([app.homeDoorOpen]), and an // empty home is a designed screen rather than a refusal ([homeEmptyRow]). // What this condition decides is whether that screen is put in front of a @@ -968,7 +1000,7 @@ func (a *app) landHome() { // call down the wire has come back, so the world here is not an answer yet // ([app.worldKnown]) — there is nothing to decide "is there work elsewhere" // from, and a greeting that waited on a round trip would be a launch that - // waited on a round trip. `esc` opens the same screen a moment later, + // waited on a round trip. `space space` opens the same screen a moment later, // with the far machine's rows on it. if a.hosted() || !a.canOpen() { return @@ -1436,7 +1468,7 @@ func (h *homeView) build() { previousLine, hadLine := h.focusedLine() previousCommand, previousQuery := h.cmd.open, h.cmd.query // An empty box is not a choice anybody has made yet, so the next character - // typed belongs to the composer again. + // typed starts on the action row again. if !h.searching() { h.picked = false } @@ -1450,8 +1482,10 @@ func (h *homeView) build() { // goes to the top of the new list, and comes back to the row it was on if // that row is still in it. // - // COMPOSING HAS NO SELECTED RESULT. Enter submits the draft until the - // person explicitly selects a match with the keyboard or pointer. + // THE ACTION ROW IS THE EXCEPTION AND IT IS THE WHOLE POINT. While something + // is typed, the cursor rests on "start a new conversation" unless the person + // walked off it — so type-and-enter still starts a chat, exactly as it did + // before this box could also search (see [homeAction]). h.cursor, h.top = h.clamp(0), 0 if h.cmd.open { // Commands share the conversation menu's initial selection. An @@ -1464,10 +1498,22 @@ func (h *homeView) build() { return } if h.searching() { - // Keep an explicitly chosen result through filtering and idle refreshes. + // THE ROW A PERSON WALKED ONTO IS THE ROW THEY ARE STILL ON, and it does + // not have to be a conversation. `picked` is the decision to stop writing + // and start choosing ([homeView.move] states it), and the question asked + // here used to be the narrower "is that CONVERSATION still on the list" — + // which is false for every other row the drop-up offers, so a cursor + // resting on a place or a command was forgotten by every rebuild. The slow + // tick rebuilds three seconds at a time ([app.refreshHome]), so a person + // who had stopped typing and touched nothing watched the selection walk + // back down to the action row on its own, over and over. h.picked = h.picked && hadLine && h.pointSame(previousLine) if !h.picked { - h.pointComposer() + // AND THE ACTION ROW IS AT THE BOTTOM NOW, so resting on it is no + // longer the same thing as resting at the top of the list ([homeAction] + // says why it moved). It is found rather than counted to: how many rows + // a query left above it is not a number this function knows. + h.pointAction() } // Either way the cursor is where it belongs: [homeView.pointSame] put it // back on the row that was chosen, and the followers below are about a @@ -1547,15 +1593,23 @@ func (h *homeView) pointItem(id string) { }) } -// pointComposer leaves the results unselected while the composer owns Enter. -// The position below the last result lets one up-arrow select that result. -func (h *homeView) pointComposer() { h.cursor = len(h.lines) } +// pointAction puts the cursor on "start a new conversation", which is the last +// line of the list whenever there is one at all. +func (h *homeView) pointAction() { + for at, line := range h.lines { + if line.kind == homeAction { + h.cursor = at + return + } + } +} // dropUp reports whether the list is drawn as a DROP-UP: its bottom row against // the box at the foot, and the matches rising above it. // -// It is exactly "something is typed", because a person is then looking at -// the box rather than reading down a roster. With nothing typed home is a dashboard +// It is exactly "something is typed", because that is exactly when the action +// row exists ([homeView.buildWorld]) and exactly when a person is looking at the +// box rather than reading down a roster. With nothing typed home is a dashboard // somebody is reading, and it hangs from the top like every other list here. func (h *homeView) dropUp() bool { return h.searching() } @@ -1583,8 +1637,10 @@ func (h *homeView) dropUp() bool { return h.searching() } // - Nothing is lifted, so the frame reads top-down as a page of everything // this machine holds, which is the one thing this surface is for. // -// WHILE SOMETHING IS TYPED the matches rise out of the box, with the best -// result nearest the seam. Clearing the box puts the dashboard back. +// WHILE SOMETHING IS TYPED it becomes a drop-up: the action row is appended as +// the list's last line, [homeLift] pushes the whole column down so that row +// lands against the box, and the matches rise above it ([homeAction] carries +// the defect that bought that). Clearing the box puts the dashboard back. // // AND IN THAT SHAPE THE RANKING IS DRAWN UPSIDE-DOWN, which is the one thing // about the drop-up that is not simply the dashboard moved. A ranked list read @@ -1598,9 +1654,28 @@ func (h *homeView) dropUp() bool { return h.searching() } // // THE FIRST MATCH THE WALK REACHES IS THE TOP-RANKED ONE. // -// One up-arrow selects the best match. Further up-arrows reach weaker ones; -// down past the last result returns to composing. Project headings remain -// above their own rows. +// It is the SECOND ↑ and not the first, because `ask here` sits between the +// action row and the matches (homeexchange.go): the two rows that do something +// with the SENTENCE are one cluster against the box, and the rows that are other +// conversations begin above them. Further ↑ walks into progressively weaker ones +// and ↓ comes back toward the box, which is the same grammar the action row +// already had. A project's heading still sits ABOVE its own rows: sections stack +// by rank and the rows inside one do too, but a name drawn under the things it +// names reads upside-down. +// +// AND THE CONVERSATION THIS WINDOW IS IN MAY NOT BE ON THE LIST AT ALL. A +// session folder nobody has spoken in yet is not a row the world reports +// (session's readSessionRow drops one whose meta names it but records no +// message), and a launch that home GREETS is exactly that folder — so +// [homeView.point] finds nothing to point at and the cursor stays where +// [homeView.clamp] left it, on the first conversation of the first project. +// That is the honest place for it, and the thing that matters is that it is a +// CONVERSATION: the card beside it is drawn from the row under the cursor and +// draws nothing for a heading, a fold line or the action row, so a cursor +// resting anywhere but a conversation is a resting home with half its screen +// empty. That is precisely what shipped, and +// [TestAFreshLaunchStillRestsOnAConversationWithItsCard] is the pin that keeps +// it from shipping twice. func (h *homeView) buildWorld() { commandRows := h.commandLines() if h.cmd.open { @@ -1689,9 +1764,25 @@ func (h *homeView) buildWorld() { }) h.projectBlock(hit, query) } - // Only matches belong above the seam. Submission is the composer's own - // action, so it does not compete with the results for a row or a cursor. + // THE ACTION ROW CLOSES THE LIST, directly above the box the words were + // typed into ([homeAction] says why it is not at the top any more). It is + // separated from the matches by the same blank line that separates two + // projects, because it is not one of them: everything above it exists, and + // it is the one row that is a thing that does not. + h.blank() + // AND `ask here` SITS DIRECTLY ON TOP OF IT, with no blank between them, + // because the two rows are one cluster: they are the two things enter can + // do with the same characters, and a gap would read as two unrelated + // offers. The cursor still RESTS on `start a new conversation` — typing and + // pressing enter means today what it meant yesterday — and this row is the + // one ↑ that asks the sentence instead of opening a conversation for it + // (homeexchange.go). + // AND THE PLACES THE WORDS MATCH SIT DIRECTLY OVER THAT CLUSTER, which + // in a drop-up is the top of the results: a place ranks first when the + // words match it, so it is the row nearest what somebody is reading + // upward from (homeplaces.go). h.lines = append(h.lines, h.placeLines(query)...) + h.lines = append(h.lines, homeLine{kind: homeAskHere}, homeLine{kind: homeAction}) } // homeHit is one project and the conversations of it that survived the box. @@ -1959,6 +2050,8 @@ func (l homeLine) sameRow(other homeLine) bool { // cursor never rests on one (homepanel_spend.go). case homeLedger, homeReadout, homePhoneNews, homePhoneMore: return l.project != "" && l.project == other.project && l.dir == other.dir + case homeAction, homeAskHere: + return true } return false } @@ -2243,7 +2336,7 @@ func (l homeLine) stop() bool { switch l.kind { // A PROJECT'S ROW IS READ AND NOT STOOD ON (owner, 2026-09-17), like spend's // lines: the rail holds nothing a cursor may rest on (homepanel_projects.go). - case homeSession, homeQuiet, homeItem, homeItemFold, + case homeSession, homeQuiet, homeAction, homeItem, homeItemFold, homeAskHere, homeProject, homeExchangeRow, homeFold: return true // the router's lane: an offered place is a door like every other door on this @@ -2298,15 +2391,17 @@ func (h *homeView) move(delta int) { break } if next >= len(h.lines) { - if h.searching() && !h.cmd.open { - at = len(h.lines) - } break } at = next } h.cursor = at - h.picked = h.cursor >= 0 && h.cursor < len(h.lines) + // WALKING OFF THE ACTION ROW IS THE DECISION. Until it is made the box is a + // message being written; after it, the person is picking from the list and + // the cursor stays where they put it through every further keystroke. + if h.cursor >= 0 && h.cursor < len(h.lines) && h.lines[h.cursor].kind != homeAction { + h.picked = true + } } // ── the keyboard ──────────────────────────────────────────────────────────── @@ -2477,15 +2572,23 @@ func (a *app) homeKey(msg tea.KeyPressMsg) tea.Cmd { // circle, unrolled onto the two keys that already point the way"), and the // errand in the pane is taken into with `→` from its own row. case "esc": - // Home is the final back destination. Dismissing a command list keeps - // the draft, just as it does in a conversation. + // ONE LAYER AT A TIME, the settings panel's rule: a box with something + // in it is cleared first, and the second esc leaves. A person who typed + // a search and meant to keep looking must not be thrown back into the + // conversation for pressing the key that means "undo that". + // + // A REQUEST OUT ON THE DISK IS THE INNERMOST LAYER OF ALL, because it is + // the only one that is doing something to another window while it stands + // (takeover.go's [app.cancelTakeover]). if a.cancelTakeover() { return nil } - if h.cmd.open { - h.cmd.dismiss(h.cmd.at) + if !h.box.empty() { + h.box.reset() h.build() + return nil } + a.closeHome() return nil // THE FOUR KEYS THAT MOVE THE CURSOR ASK FOR NOTHING HERE. What the card @@ -2910,11 +3013,7 @@ func (h *homeView) rebuild() { h.lines = h.lines[:0] // phone lane: at [tierPhone] the column is an inbox (homephone.go). h.buildFor() - if h.searching() && !h.picked { - h.pointComposer() - } else { - h.cursor = h.clamp(h.cursor) - } + h.cursor = h.clamp(h.cursor) } // buildFor is which SHAPE the column takes, and it is asked in the two places @@ -2941,7 +3040,8 @@ func (h *homeView) buildFor() { // THE SWITCHER IS THE RESTING SHAPE AND THE DROP-UP IS THE TYPED ONE // (place_home.go). With nothing in the box this screen is one flat ranked // list of everything on the machine; the first character makes it the - // ranked-by-[homeRank] drop-up, with the best match against the box. + // ranked-by-[homeRank] drop-up it has always been, with `ask here` and the + // action row against the box. if h.searching() { h.buildWorld() return @@ -2949,8 +3049,7 @@ func (h *homeView) buildFor() { h.buildGrid() } -// homeSubmit chooses the submission door without introducing a selectable -// action row among the search results. +// homeSubmit runs the draft selected by the new-conversation row or a send tag. func (a *app) homeSubmit() tea.Cmd { h := &a.home typed := strings.TrimSpace(h.box.String()) @@ -2975,12 +3074,6 @@ func (a *app) homeSubmit() tea.Cmd { h.say(slashTagRefusal, "") return nil } - if len(tags) == 1 { - tag := tags[0] - if commandDoor(string(h.box.value[tag.from+1:tag.to])) == sendDoorAsk { - return a.runAskCommand(removeSlashTag(h.box.value, tag)) - } - } return a.homeStart(typed) } @@ -3024,6 +3117,8 @@ func (a *app) homeEnter() tea.Cmd { return nil } switch line.kind { + case homeAction: + return a.homeSubmit() case homePlace: // ENTER GOES THERE, AND GOING TO A PLACE LEAVES YOU THERE (SCREEN 1g). // The box is not cleared on the way — the sentence is the person's, and @@ -3040,6 +3135,9 @@ func (a *app) homeEnter() tea.Cmd { // says — run it bare, or hold the box for the words it takes // (homeslash.go's [app.homeRunCommand]). return a.homeRunCommand(line) + case homeAskHere: + // The same sentence, asked rather than opened (homeexchange.go). + return a.askHere(strings.TrimSpace(h.box.String())) case homeExchangeRow: // ENTER ON AN ERRAND HANDS IT THE KEYBOARD. There is nothing to open — // the exchange is already drawn beside the row, or on a narrow frame is @@ -3630,8 +3728,32 @@ func (h *homeView) typedPlace(text string) string { return "" } -// runLabel reports whether the draft is a command, keeping pasted paths -// literal while preserving the existing slash dispatcher. +// startLabel is what the action row says enter will do, which on a screen where +// enter has two possible meanings must be legible without looking away from the +// list. +func (h *homeView) startLabel() string { + text := strings.TrimSpace(h.box.String()) + if text == "" { + return homeStartWord + } + if place := h.pastedProject(); place != "" { + return homeStartWord + " in " + place + } + // THE SAME QUESTION IN THE SAME ORDER [app.homeEnter] ASKS IT, which is the + // whole reason this label exists: a row that ranked the two readings of a + // leading slash differently from the key would be wrong about the one line + // it is there to be right about. + if word := h.runLabel(text); word != "" { + return word + } + return homeStartWord + ": " + strconv.Quote(text) +} + +// runLabel is the action row's label when what is typed is a COMMAND, and "" for +// everything else — which makes it the one question `is this line a command`, +// asked by the label, by the foot and by enter itself so that the three cannot +// come to disagree ([app.homeEnter], [app.homeHintWords], homephone.go's narrow +// column). // // A LEADING SLASH IS NOT ENOUGH, because `/tmp/alpha` is a place. The two are // separated in THE ORDER THE DISPATCHER ITSELF SEPARATES THEM (app.go's @@ -3754,26 +3876,120 @@ func homeBucketOf(transcript string) string { // homeDoorWord is the dim advertisement at the foot of an idle conversation, // and it is written in the hint slot's own grammar — the key, then the noun, // exactly as `ctrl+g tasks` is (render.go's [app.hintWord]). -const homeDoorWord = "esc home" - -// The footer names the destination of Escape at this depth. -func (a *app) escapeDoorWord() string { - if a.roomOpen() { - return "esc main" +const homeDoorWord = "space space home" + +// homeGesture is TWO SPACES TYPED INTO AN EMPTY BOX, and it is the way back to +// home — from inside a conversation, and from every place standing over it. +// +// WHY A GESTURE AND NOT A KEY. Every ctrl+letter is taken. `esc` was the +// obvious candidate and is not available: on an idle conversation it already +// arms rewind (the hint slot says `esc again to rewind`) and it already drops a +// message parked against a turn that has ended, and a third meaning on one key +// in that state is how a surface becomes unpredictable. What was left is a +// gesture, and a leading run of spaces in an empty message is the one keystroke +// on this surface that is reliably NOTHING: a message that begins with two +// spaces is a message nobody meant to send that way. +// +// THE INTERMEDIATE SPACE IS REAL, AND THAT IS THE POINT. The first space types +// itself, plainly, the way every other character does — there is no pending +// state, no timer, and no ghost. The SECOND one, arriving to find a box that +// still SHOWS nothing with that space behind the caret, takes the whole draft +// away and opens home. So somebody who genuinely wanted a leading space types it +// and carries on: space then `x` leaves ` x`, untouched, because the gesture only +// ever fires on a space and only ever over a box with no words in it. +// +// WHEREVER THE DOOR IS ADVERTISED, TWO SPACES OPEN IT — and the two halves used +// to disagree, which is the bug this asks [editor.empty] rather than counting +// runes. The foot draws `space space home` whenever the box holds nothing a +// person would call text ([app.homeDoorShowing]), and the gesture demanded a box +// holding EXACTLY one space. Every draft the two disagreed about was a door +// drawn over a gesture that could not fire — and one of them is easy to land in +// and impossible to see: `ctrl+enter` and `shift+enter` (standmark.go, +// bargein.go) arrive as a bare `ctrl+j` on every terminal that cannot spell +// them, and `ctrl+j` opens a line (input.go). Two of those on an empty box left +// `\n\n` in it, the frame drew an empty box over an advertised door, and the +// chord was dead in that conversation for good — [writeDraft] kept the invisible +// draft and the next window on the directory adopted it (draft.go). +// +// THE CARET IS WHAT "THE SPACE YOU JUST TYPED" MEANS, rather than the end of the +// draft: a space typed at the FRONT of a box holding a blank line is the same +// two keystrokes against the same blank-looking box as one typed after it. +// +// PASTED TEXT CANNOT FIRE IT. A bracketed paste arrives as its own message and +// never reaches this router at all, and a paste whose brackets leak is absorbed +// key by key into the bracket's buffer above it (app.go's [app.pasteKey]) — +// so two spaces at the start of pasted text are two characters, not a door. The +// one hole is a terminal that does not speak bracketed paste at all, where a +// paste IS a stream of keystrokes and there is nothing anywhere in this program +// that can tell it from typing. +// +// A RUNNING TURN IS NO OBSTACLE. Home takes the frame the way the settings +// panel does, and the settings panel does not disturb a turn: the stream events +// are their own messages and land whatever is drawn over them (app.go's +// Update). The turn goes on underneath and is still there when esc comes back. +func (a *app) homeGesture(msg tea.KeyPressMsg) bool { + if !a.homeDoorOpen() { + return false } - return homeDoorWord + return a.homeDoorArmed(&a.input, msg) } -// homeDoorOpen reports whether the Home destination can be opened here. +// homeDoorArmed is the part of the door that is about THE BOX, asked the same +// way whichever box the press landed in — the conversation's draft +// ([app.homeGesture]) or the box the standing place types into +// ([app.placeHomeGesture]). A door with two laws about emptiness would be a +// door that behaved differently depending on which room a person was standing +// in, which is exactly what this keeps from happening. +// +// The law is the one [app.homeGesture] always kept: a space, a box that shows +// nothing ([editor.empty]), and the space the person just typed behind the +// caret. It answers for a nil box as well, because a place with no box has no +// door — there is nothing to type two spaces into. +func (a *app) homeDoorArmed(box *editor, msg tea.KeyPressMsg) bool { + if msg.Key().Text != " " || box == nil { + return false + } + return box.empty() && box.cursor > 0 && + box.value[box.cursor-1] == ' ' +} + +// homeDoorOpen reports whether home is reachable from where this keypress is +// standing — any place or any conversation, except home itself, where the +// gesture is a no-op and the foot draws no door. It is the gesture's guard and +// the advertisement's condition, which is deliberate: a door that is drawn is +// a door that works. +// +// HOME IS ALWAYS REACHABLE, AND AN EMPTY HOME IS A SCREEN. This used to ask one +// more thing — that the machine held a conversation other than this one — on +// the argument that a door which opened on nothing should be neither drawn nor +// bound. That argument confused two questions. Whether home should GREET a +// launch that has nowhere else to go is [app.landHome]'s, and it still says no. +// Whether a person who asks for home should get it is this one's, and the +// answer is yes on any machine home can read: a fresh machine gets the same +// head, columns and foot as a full one, with this conversation's row under its +// project and `nothing here yet` where the rest will be ([homeEmptyRow]). +// A gesture that silently typed two spaces on the one day a person first tried +// it was the surface teaching them the door does not exist. +// +// The one condition left is about the machine, not its contents: the surface has +// a disk to read ([app.canOpen]). +// +// --host USED TO BE A SECOND CONDITION AND IS NOT ONE ANY MORE. Home refused +// over --host, so a door onto a refusal was correctly kept shut; then it opened +// with one sentence where its rows would be; and it now opens on THE FAR +// MACHINE'S OWN PROJECTS ([app.readWorld]) with that machine's name at the right +// end of the tab bar. A gesture that worked from `alt+1` and not from two spaces +// would be the surface teaching two different answers to one question. func (a *app) homeDoorOpen() bool { return a.canOpen() && !a.at(pageHome) } // homeDoorShowing reports whether the foot of the conversation should advertise -// it: Home is reachable and no copy or rewind mode owns the foot. The draft -// may contain words because back navigation preserves them. +// it: the door is open, and the box is EMPTY. It vanishes on the first +// character typed, because it is a door and not chrome — the space it takes is +// the keys row's, which the frame already has (render.go's [app.footHint]). func (a *app) homeDoorShowing() bool { - return (a.roomOpen() || a.homeDoorOpen()) && !a.copy.on && !a.rew.on + return a.homeDoorOpen() && a.input.empty() && !a.copy.on && !a.rew.on } // homeDoorPress is a click on that advertisement. @@ -3785,9 +4001,7 @@ func (a *app) homeDoorPress(x, y int) (tea.Cmd, bool) { if !ok || mark.kind != a.hintRowKind() { return nil, false } - // Re-enter through the event router so task rooms and roster focus get - // the same first refusal as a physical Escape press. - return func() tea.Msg { return tea.KeyPressMsg{Code: tea.KeyEscape} }, true + return a.openHome(), true } // ── the pointer ───────────────────────────────────────────────────────────── @@ -3867,7 +4081,9 @@ func (a *app) homePress(x, y int) tea.Cmd { // and a plain selection all leave the hand in the same place. a.homeTakeList() a.home.cursor = at - a.home.picked = true + if a.home.lines[at].kind != homeAction { + a.home.picked = true + } a.touch() // A CLICK ON A ROW IS `enter` ON IT, the one grammar every place keeps // (pages.go's [place.press]): the pointer resting is the preview, and the @@ -3893,7 +4109,7 @@ func (a *app) homePress(x, y int) tea.Cmd { // gesture nobody can take back. func homeClickSpends(line homeLine) bool { switch line.kind { - case homeCommand: + case homeAction, homeAskHere, homeCommand: return true } return false @@ -4243,7 +4459,8 @@ func (a *app) homeBody(left, right, room int, pal palette) []homeDrawn { } // THE DROP-UP LIFTS THE LIST AND LEAVES THE CARD WHERE IT IS. While something // is typed the left column hangs from the BOTTOM of the region so that its - // best match lands nearest the box at the foot. At rest it hangs from the top, because at rest this is a + // last row — the action row — lands against the box at the foot + // ([homeAction]). At rest it hangs from the top, because at rest this is a // page somebody is reading rather than a thing they are typing at (the block // above [homeView.buildWorld]). // @@ -4462,7 +4679,22 @@ func (a *app) homeLine(line homeLine, at, width int, pal palette) string { // A COMMAND, OFFERED BECAUSE THE WORDS MATCH ITS NAME OR AN ALIAS // (homeslash.go). return a.homeCommandRow(line, at, width, pal) - + case homeAskHere: + // The same shape as the action row under it and the same words quoted + // back, because they are the two readings of one sentence + // (homeexchange.go). + label := homeAskHereWord + if text := strings.TrimSpace(h.box.String()); text != "" { + label += ": " + strconv.Quote(text) + } + return overlayRow(homeAskHereGlyph+" "+label, "", at == h.cursor, false, at == h.hover && at == h.cursor, width, pal) + case homeAction: + // It carries the words back at the person, cut to fit. The box at the + // foot holds them too, but the box is where you are typing and this is + // what enter will DO with it — and on a screen where enter has two + // possible meanings, the one it currently has must be legible without + // looking away from the list. + return overlayRow(homeStartGlyph+" "+h.startLabel(), "", at == h.cursor, false, at == h.hover && at == h.cursor, width, pal) } // OUR OWN ROWS ARE READ FROM THE AGENT AND NOT FROM THE PRESENCE FILE. The // file is written on a five-second heartbeat and believed for fifteen, which @@ -5215,10 +5447,49 @@ func (a *app) homeHintWords() string { // standing beside the column with no line saying how to reach it is the // half of the toggle nobody finds; the list's own verbs come first, // because that is the zone the hand is in. - return "↑↓ move · enter or tab answer this " + homeAskHereWord + return "↑↓ move · enter or tab answer this " + homeAskHereWord + " · esc close" } line, _ := a.home.focusedLine() switch { + case line.kind == homeAskHere: + // The row that asks rather than opens, and the chord that reaches it + // without walking up to it (homeexchange.go). + return "enter asks this here and keeps the record · ↓ start a conversation instead · esc clear" + case line.kind == homeAction: + // The THREE readings of the box, all said, because all three are true of + // what is on screen right now: enter opens a conversation for it, + // ctrl+enter asks it here (homeexchange.go), and ↑ walks into what it + // found. + // + // THE ARROW IS ↑ BECAUSE THE MATCHES ARE ABOVE. The action row is the last + // line of the list, against the box ([homeAction]), so walking into the + // results is walking up the screen — and a hint naming the other arrow + // would be this line lying about the next keystroke. It names the arrow + // and not a count, because the row it passes through on the way is the + // one named two clauses earlier. + // AND A COMMAND IS THE THIRD READING, so the foot says so rather than + // promising a conversation the key will not start. `ask here` is still + // true of a "/" line — the words can be asked about as words — so the + // clause that changes is the one that stopped being true. + // + // `ask here` IS ↑ AND NOT A CHORD ANY MORE. `ctrl+enter` is still bound + // (above) and is no longer advertised: most terminals cannot send it at + // all, and `alt+enter` — the spelling that survives everywhere — belongs + // to the task layer on every place (placekeys.go). What every terminal + // CAN do is press the arrow key, and the row is already there: `? ask + // here: "…"` sits directly above `+ start a new conversation: "…"` with + // the cursor resting on the latter (homeexchange.go), so one ↑ is the ask + // and two is the first match. A foot that went on naming a chord a hand + // cannot send was the screen advertising a key that does not exist. + // + // AND THE ORDER IS THE DROP ORDER. [hintFit] drops the clause nearest the + // way out — `↑↑ pick a match` — first, which is the right one to lose: + // ↑↓ walking a list is the key the resting foot already names (`↑↓ pick`) + // and the map names again. A wide frame still says all three. + if a.home.runLabel(strings.TrimSpace(a.home.box.String())) != "" { + return "enter runs this command · ↑ ask here · ↑↑ pick a match · esc clear" + } + return "enter starts a new conversation and sends this · ↑ ask here · ↑↑ pick a match · esc clear" case a.home.gridOn() && (line.kind == homeSession || line.kind == homeItem || line.kind == homeLedger): // ONE SENTENCE ON EVERY ROW OF THE GRID. A conversation, a standing // order, a landing, a line of news: each used to say its own thing here @@ -5234,30 +5505,30 @@ func (a *app) homeHintWords() string { // The panel's fold is a toggle and the foot says which way it will go; // the words are the ones every fold door on every place uses // (placeprose.go's [foldEnterWord]). - return foldEnterWord(!line.folded) + return foldEnterWord(!line.folded) + " · esc close" case line.kind == homeQuiet && line.folded: - return "enter or → show them" + return "enter or → show them · esc close" case line.kind == homeQuiet: - return "enter or ← fold them away" + return "enter or ← fold them away · esc close" case line.kind == homeItemFold && line.folded: - return "enter or → show them" + return "enter or → show them · esc close" case line.kind == homeItemFold: - return "enter or ← fold them away" + return "enter or ← fold them away · esc close" case line.kind == homeProject && line.folded: - return "enter or → open this project here" + return "enter or → open this project here · esc close" case line.kind == homeProject: - return "enter or ← fold this project away" + return "enter or ← fold this project away · esc close" case line.kind == homeLedger: // THE ROW SAYS WHERE IT GOES, so the hint says what the key does with it // and never repeats the name (place_home.go). - return "enter opens the place this happened in" + return "enter opens the place this happened in · esc close" case line.kind == homeItem: // THE KEYS THE CARD BESIDE IT ALREADY NAMES, said once more where the // hand is. One vocabulary, two places (homestanding.go's // [homeItemActions]). Grid rows already took the resting sentence above. - return homeItemActions + return homeItemActions + " · esc close" case a.home.searching(): - return "enter open · ↓ back to starting a new conversation" + return "enter open · ↓ back to starting a new conversation · esc clear" } // At rest only the draft controls are added by homeHint. return "" diff --git a/internal/tui3/home_test.go b/internal/tui3/home_test.go index 029a88be20..0648de054a 100644 --- a/internal/tui3/home_test.go +++ b/internal/tui3/home_test.go @@ -38,8 +38,6 @@ type homeLab struct { pinned time.Time } -const homeStartWord = "start a new conversation" - func newHomeLab(t *testing.T) *homeLab { t.Helper() return &homeLab{t: t, root: t.TempDir(), work: t.TempDir()} @@ -334,8 +332,38 @@ func TestHomeTakesExactlyTheWholeFrame(t *testing.T) { } } +func TestHomeEscGoesBackToTheConversation(t *testing.T) { + lab := newHomeLab(t) + mine := lab.session("-tmp-alpha", "aaaa000000000001", "one", "/tmp/alpha", time.Now()) + a := lab.app(mine) + a.openHome() + a.homeKey(key("esc")) + if a.at(pageHome) { + t.Fatal("esc did not close home") + } +} + // esc peels one layer: a box with something in it is cleared before the screen // is left. +func TestHomeEscClearsTheBoxBeforeItLeaves(t *testing.T) { + lab := newHomeLab(t) + mine := lab.session("-tmp-alpha", "aaaa000000000001", "one", "/tmp/alpha", time.Now()) + a := lab.app(mine) + a.openHome() + a.homeKey(key("@")) + a.homeKey(key("x")) + a.homeKey(key("esc")) + if !a.at(pageHome) { + t.Fatal("the first esc left home instead of clearing the box") + } + if !a.home.box.empty() { + t.Fatalf("the box still holds %q", a.home.box.String()) + } + a.homeKey(key("esc")) + if a.at(pageHome) { + t.Fatal("the second esc did not close home") + } +} // THE EMPTINESS LAW. A conversation that ran nothing and spent nothing says // nothing about either. @@ -593,8 +621,10 @@ func TestHomeStopsSayingNeedsYouWhenTheWindowIsGone(t *testing.T) { // ── the omnibox ───────────────────────────────────────────────────────────── -// Typing filters the world without selecting a result or adding an action row. -func TestTypingFiltersLiveWhileTheComposerStaysTheDefault(t *testing.T) { +// TYPING DOES BOTH JOBS AT ONCE. The characters are a new conversation waiting +// to be sent AND a live query over the machine, and the cursor stays on the +// action row so that type-and-enter means exactly what it always meant. +func TestTypingFiltersLiveWhileTheActionRowStaysTheDefault(t *testing.T) { lab := newHomeLab(t) now := time.Now() mine := lab.session("-tmp-alpha", "aaaa000000000001", "porting the resume picker", "/tmp/alpha", now) @@ -613,11 +643,11 @@ func TestTypingFiltersLiveWhileTheComposerStaysTheDefault(t *testing.T) { t.Fatalf("the query kept a conversation that does not match:\n%s", text) } line, ok := a.home.focusedLine() - if ok { - t.Fatalf("typing unexpectedly selected a result (kind %v)", line.kind) + if !ok || line.kind != homeAction { + t.Fatalf("the cursor left the action row while typing (kind %v)", line.kind) } - if strings.Contains(text, homeStartWord+`: "pricing"`) { - t.Fatalf("a removed action row was rendered:\n%s", text) + if !strings.Contains(text, homeStartWord+`: "pricing"`) { + t.Fatalf("the action row does not say what enter will do:\n%s", text) } } @@ -640,15 +670,27 @@ func TestEnterStillStartsAChatWithMatchesOnScreen(t *testing.T) { } runCmd(a.homeEnter()) if a.at(pageHome) { - t.Fatal("Enter while composing left home open") + t.Fatal("enter on the action row left home open") } if len(next.sent) != 1 || next.sent[0] != "pricing" { t.Fatalf("the new conversation was sent %v", next.sent) } } -// One up-arrow selects the best match; down returns to composing. -func TestWalkingUpFromTheComposerPicksFromTheList(t *testing.T) { +// Walking UP off the action row is the decision to pick from the list instead, +// and it sticks. +// +// IT USED TO BE ↓, and the arrow turned round with the action row. The row sits +// at the BOTTOM of the list now, against the box a person is typing into +// ([homeAction]), so the matches are above it and walking into them is walking +// up the screen. WHICH match the walk reaches is +// [TestTheBestMatchSitsNextToTheActionRow]. +// +// IT IS TWO ↑ AND NOT ONE, because `ask here` sits between the action row and +// the matches (homeexchange.go): the two rows that do something with the +// SENTENCE are one cluster against the box, and the rows that are other +// conversations begin above them. +func TestWalkingOffTheActionRowPicksFromTheList(t *testing.T) { lab := newHomeLab(t) now := time.Now() mine := lab.session("-tmp-alpha", "aaaa000000000001", "pricing research", "/tmp/alpha", now) @@ -658,47 +700,110 @@ func TestWalkingUpFromTheComposerPicksFromTheList(t *testing.T) { a.homeKey(key(string(r))) } a.homeKey(key("up")) + if line, ok := a.home.focusedLine(); !ok || line.kind != homeAskHere { + t.Fatalf("the first ↑ should reach `ask here` (kind %v)", line.kind) + } + a.homeKey(key("up")) if row := a.home.focused(); row.Transcript != mine { t.Fatal("↑ did not land on the match") } a.homeKey(key("i")) if row := a.home.focused(); row.Transcript != mine { - t.Fatal("typing after ↑ lost the selected match") + t.Fatal("typing after ↑ threw the cursor back to the action row") } - // One down-arrow from the nearest result returns to composing. + // And ↓ walks back down through the same two rows to the action row, which + // is where the sentence is. + a.homeKey(key("down")) a.homeKey(key("down")) - if line, ok := a.home.focusedLine(); ok { - t.Fatalf("↓ did not return to composing (kind %v)", line.kind) + if line, ok := a.home.focusedLine(); !ok || line.kind != homeAction { + t.Fatalf("↓ did not come back to the action row (kind %v)", line.kind) } } -// Results stay next to the message box without submission rows between them. +// TYPING IS ONE CLUSTER AT THE FOOT, and this pins the geometry that makes it +// one. +// +// The defect it answers: the characters landed in the box at the very bottom of +// the frame while the row saying what enter would do with them stood at the very +// top, so the eye had to jump between the two ends of the screen and the cursor +// was at one end while the caret blinked at the other. The action row now sits +// on the LAST body row — directly above the rule and the box — with the matches +// rising above it. +// +// THE RESTING SCREEN IS THE OTHER SHAPE, and [TestHomeWithNothingTypedHangsFromTheTop] +// pins it: a dashboard from the top with the preview card beside it. The lift is +// what typing does, and only what typing does. func TestTypingClustersAtTheFootOfHome(t *testing.T) { lab := newHomeLab(t) - mine := lab.session("-tmp-alpha", "aaaa000000000001", "pricing research", "/tmp/alpha", time.Now()) + now := time.Now() + mine := lab.session("-tmp-alpha", "aaaa000000000001", "pricing research", "/tmp/alpha", now) + lab.session("-tmp-beta", "bbbb000000000001", "pricing sheet import", "/tmp/beta", now.Add(-time.Hour)) + a := lab.app(mine) a.openHome() - typeHome(a, "pricing") + for _, r := range "pricing" { + a.homeKey(key(string(r))) + } + width, height := a.size() - rows, _, _, caretY := a.homeFrame(width, height) + lines, _, _, caretY := a.homeFrame(width, height) + rows := make([]string, len(lines)) + for i, line := range lines { + rows[i] = strings.TrimRight(ansi.Strip(line), " ") + } + action := -1 + for i, row := range rows { + if strings.Contains(row, homeStartWord+`: "pricing"`) { + action = i + } + } + if action < 0 { + t.Fatalf("the action row is not on the frame:\n%s", strings.Join(rows, "\n")) + } + // THE BOX IS THE ROW THE CARET IS ON, and the action row is three rows above + // it: the list's padding row, then the frame's own foot rule (home.go's + // [app.homeFrame] states why the list never touches that rule). Anything more + // than that is the split this test exists to stop coming back. + if caretY-action != 3 { + t.Fatalf("the action row is %d rows above the box, want 3:\n%s", caretY-action, strings.Join(rows, "\n")) + } + if !strings.Contains(rows[caretY], "pricing") { + t.Fatalf("row %d is not the box:\n%s", caretY, strings.Join(rows, "\n")) + } + // AND THE MATCHES ARE ABOVE IT, not below — the list grew upward out of the + // box rather than downward from the title. match := -1 for i, row := range rows { - plain := ansi.Strip(row) - if strings.Contains(plain, "Pricing Research") { + if strings.Contains(row, "Pricing Research") { match = i } - if strings.Contains(plain, homeStartWord) || strings.Contains(plain, "? ask here:") { - t.Fatalf("action row remains: %s", plain) - } - } - if match < 0 || caretY-match != 3 { - t.Fatalf("nearest result at %d, caret at %d; want a three-row gap", match, caretY) } - if _, ok := a.home.focusedLine(); ok { - t.Fatal("typing selected a search result") + if match < 0 || match > action { + t.Fatalf("the matches are not above the action row (match %d, action %d):\n%s", + match, action, strings.Join(rows, "\n")) } - if hint := a.homeHintWords(); strings.Contains(hint, "ask here") || strings.Contains(hint, "starts a new") { - t.Fatalf("submission hint remains: %s", hint) + // The hint under the box names the arrow that is actually true of the screen — + // ↑, because the matches rise ABOVE the action row the caret sits against. + // + // IT IS ASKED OF THE SENTENCE AND NOT OF THE DRAWN ROW, and that is not a + // weaker question. The foot is a hundred and fourteen cells with the router's + // keys on it and this frame is a hundred wide, so [hintFit] drops the clause + // nearest the way out to make it fit — by design, and the ladder it drops down + // is pinned by [TestAHintDropsWholeClausesAndKeepsTheWayOut]. Asked of the + // drawn row this assertion was really asking how wide the lab happens to be, + // and it passed for a year only because the old fitter sliced the tail off + // mid-word instead — the foot on this very screen read `… · tab next …`. The + // law it was written for is about the arrow, so the arrow is where it looks. + if hint := a.homeHintWords(); !strings.Contains(hint, "↑ pick a match") { + t.Fatalf("the hint names the wrong arrow: %s", hint) + } + // AND THE FOOT THAT IS DRAWN IS STILL WHOLE CLAUSES OF THAT SENTENCE, never a + // word with its end sliced off. + for _, clause := range strings.Split(strings.TrimSpace(rows[len(rows)-1]), railSep) { + if !strings.Contains(a.homeHint(), clause) { + t.Fatalf("the foot drew %q, which is not a clause of the hint:\n%s", + clause, rows[len(rows)-1]) + } } } @@ -905,18 +1010,31 @@ func TestHomesRestingFootIsTheDesignsSentence(t *testing.T) { // ESC STILL WORKS, which is why losing the clause is a wording change and // not a capability going quiet. a.homeKey(key("esc")) - if !a.at(pageHome) { - t.Fatal("esc left home") + if a.at(pageHome) { + t.Fatal("esc did not close home") } - // Home has no back destination once its local layers are dismissed. - if strings.Contains(a.homeHintWords(), "esc") { - t.Fatal("Home advertised an unavailable back action") + // AND EVERY ROW THAT IS NOT THE RESTING ONE STILL ENDS WITH IT. The old law + // held for the whole screen; it holds now for the rows the design does not + // spell itself, which is every state home enters once a person acts. + a.openHome() + for _, r := range "pricing" { + a.homeKey(key(string(r))) + } + // The clause names what esc will do on THAT row — `esc clear` on a typed box, + // `esc close` on a card — so what is demanded is the key in the last slot + // rather than one spelling of it. + hint := a.placeHint() + clauses := strings.Split(hint, " · ") + if last := clauses[len(clauses)-1]; !strings.HasPrefix(last, "esc ") { + t.Fatalf("a typed home's hint reads %q, want a way out on the end", hint) } - } -// Typing clears result selection; clearing the box restores the resting list. +// THE CURSOR MOVES BETWEEN THE TWO STATES, and that is the accepted price of +// keeping the dashboard. Each state's geometry is pinned on its own: at rest the +// cursor is up in the list, and the first character takes it to the foot with the +// action row. Clearing the box brings it back. func TestTheCursorGoesToTheFootWhileTypingAndBackAtRest(t *testing.T) { lab := newHomeLab(t) now := time.Now() @@ -941,13 +1059,14 @@ func TestTheCursorGoesToTheFootWhileTypingAndBackAtRest(t *testing.T) { t.Fatalf("the resting cursor is on %q, want this window's conversation", homeName(row)) } - // The composer stays unselected until the person chooses a result. + // TYPING: the action row, on the last body row — FIVE up from the bottom of + // the frame, because the body now ends one row short of the rule: the padding + // row, then the rule, the box and the hint. a.homeKey(key("p")) - if line, ok := a.home.focusedLine(); ok { - t.Fatalf("the first character kept a result selected (kind %v)", line.kind) + if line, ok := a.home.focusedLine(); !ok || line.kind != homeAction { + t.Fatalf("the first character did not put the cursor on the action row (kind %v)", line.kind) } - if _, ok := a.home.focusedLine(); ok { - at := homeCursorY(t, a) + if at := homeCursorY(t, a); at != height-5 { t.Fatalf("the typing cursor is on row %d of %d, want the last body row %d:\n%s", at, height, height-5, homeText(a)) } @@ -970,8 +1089,11 @@ func TestTheCursorGoesToTheFootWhileTypingAndBackAtRest(t *testing.T) { // took three keystrokes, which is the ranking being drawn at the wrong end of the // column. The scoring was never wrong; the drawing was. -// The nearest result is the best match, and further up-arrows reach weaker matches. -func TestTheBestMatchSitsNextToTheComposer(t *testing.T) { +// ONE ↑ FROM THE ACTION ROW IS THE TOP-RANKED MATCH. That is the whole law, and +// it is asserted against the scores themselves rather than against a list of +// names, so a change to [homeRank] cannot quietly make this test agree with a +// column it no longer describes. +func TestTheBestMatchSitsNextToTheActionRow(t *testing.T) { lab := newHomeLab(t) now := time.Now() // Three hits of DIFFERENT quality on "pricing": the bare name is the strongest, @@ -1025,11 +1147,15 @@ func TestTheBestMatchSitsNextToTheComposer(t *testing.T) { t.Fatalf("every match tied, so the order proves nothing: %+v", drawn) } - // The composer stays unselected until the person chooses a result. - if line, ok := a.home.focusedLine(); ok { - t.Fatalf("the composer unexpectedly selected a result (kind %v)", line.kind) + // AND THE ACTION ROW IS STILL BELOW THEM ALL, so the best match is the FIRST + // conversation the walk reaches rather than the row furthest from the key. + // The row between them is `ask here` (homeexchange.go), which is the other + // thing enter can do with the sentence and not a match. + if line, ok := a.home.focusedLine(); !ok || line.kind != homeAction { + t.Fatalf("the cursor did not rest on the action row (kind %v)", line.kind) } a.homeKey(key("up")) + a.homeKey(key("up")) if got := homeName(a.home.focused()); got != best.name { t.Fatalf("walking up landed on %q, want the top-ranked %q (%+v)", got, best.name, drawn) } @@ -1040,12 +1166,18 @@ func TestTheBestMatchSitsNextToTheComposer(t *testing.T) { t.Fatalf("walking up reached %q, want %q (%+v)", got, drawn[i].name, drawn) } } - // The composer stays unselected until the person chooses a result. + // And ↓ comes back down toward the box, through `ask here` and onto the + // action row — one step per match, plus the one for the row between them + // (homeexchange.go). for range drawn { a.homeKey(key("down")) } - if line, ok := a.home.focusedLine(); ok { - t.Fatalf("↓ did not return to composing (kind %v)", line.kind) + if line, ok := a.home.focusedLine(); !ok || line.kind != homeAskHere { + t.Fatalf("↓ did not walk back to `ask here` (kind %v)", line.kind) + } + a.homeKey(key("down")) + if line, ok := a.home.focusedLine(); !ok || line.kind != homeAction { + t.Fatalf("↓ did not walk back to the action row (kind %v)", line.kind) } } @@ -1093,7 +1225,7 @@ func TestTheInvertedDropUpKeepsHeadingsAboveTheirRows(t *testing.T) { // card exists to stop. // // The first ↑ here lands on the TOP-RANKED match, which is -// [TestTheBestMatchSitsNextToTheComposer]'s law; what this one is about is that +// [TestTheBestMatchSitsNextToTheActionRow]'s law; what this one is about is that // the card changes with the cursor whichever row that turns out to be. func TestThePreviewCardFollowsTheCursorWhileTyping(t *testing.T) { lab := newHomeLab(t) @@ -1116,15 +1248,23 @@ func TestThePreviewCardFollowsTheCursorWhileTyping(t *testing.T) { t.Fatalf("a %d-column frame lent the detail pane nothing", width) } - // The composer stays unselected until the person chooses a result. - if line, ok := a.home.focusedLine(); ok { - t.Fatalf("typing unexpectedly selected a result (kind %v)", line.kind) + // ON THE ACTION ROW THE PANE IS EMPTY, and that is the emptiness law rather + // than an omission: "start a new conversation" is a chat that does not exist + // yet, so there is nothing true to preview about it. + if line, ok := a.home.focusedLine(); !ok || line.kind != homeAction { + t.Fatalf("typing did not rest the cursor on the action row (kind %v)", line.kind) } if card := a.homeDetail(right, 12, a.pal); len(card) != 0 { t.Fatalf("the pane previewed a conversation that does not exist yet:\n%s", strings.Join(card, "\n")) } - // The composer stays unselected until the person chooses a result. + // ↑ ONTO A MATCH DRAWS THAT MATCH'S CARD. Two of them: `ask here` is the row + // in between, and it is a thing that does not exist yet exactly as the action + // row is, so its pane is empty for the same reason (homeexchange.go). + a.homeKey(key("up")) + if card := a.homeDetail(right, 12, a.pal); len(card) != 0 { + t.Fatalf("the pane previewed the `ask here` row:\n%s", strings.Join(card, "\n")) + } a.homeKey(key("up")) first := a.home.focused() if first.Transcript == "" { @@ -1153,11 +1293,13 @@ func TestThePreviewCardFollowsTheCursorWhileTyping(t *testing.T) { t.Fatalf("the card kept the row the cursor left:\n%s", card) } - // The composer stays unselected until the person chooses a result. + // …AND ↓ BACK ONTO THE ACTION ROW EMPTIES IT AGAIN. Three steps: two matches + // and the `ask here` row between them and the box (homeexchange.go). + a.homeKey(key("down")) a.homeKey(key("down")) a.homeKey(key("down")) - if line, ok := a.home.focusedLine(); ok { - t.Fatalf("↓ did not return to composing (kind %v)", line.kind) + if line, ok := a.home.focusedLine(); !ok || line.kind != homeAction { + t.Fatalf("↓ did not come back to the action row (kind %v)", line.kind) } if card := a.homeDetail(right, 12, a.pal); len(card) != 0 { t.Fatalf("the pane kept a card after the cursor left the match:\n%s", strings.Join(card, "\n")) @@ -1235,7 +1377,9 @@ func TestAQueryMatchesWhatATaskCameTo(t *testing.T) { if strings.Contains(text, "Tuesday") { t.Fatalf("it matched a conversation with no such outcome:\n%s", text) } - // The composer stays unselected until the person chooses a result. + // ↑ walks off the action row, past `ask here` (homeexchange.go), and up into + // the match — which is where the matches are now ([homeAction]). + a.homeKey(key("up")) a.homeKey(key("up")) if !strings.Contains(homeText(a), "Rewrote the postgres") { t.Fatalf("the pane does not show what the work came to:\n%s", homeText(a)) @@ -1268,7 +1412,7 @@ func TestNeedsYouOutranksAColdRowItTiesWith(t *testing.T) { t.Fatalf("expected two matches, got %d", len(order)) } // THE TOP-RANKED ROW IS THE LAST ONE DRAWN, because the drop-up is read - // upward out of the box ([TestTheBestMatchSitsNextToTheComposer] states the + // upward out of the box ([TestTheBestMatchSitsNextToTheActionRow] states the // law). The RANKING is what this test is about and it has not moved; only // which end of the column it is written at. if !order[len(order)-1].NeedsPerson() { @@ -1287,6 +1431,26 @@ func TestNeedsYouOutranksAColdRowItTiesWith(t *testing.T) { } // esc peels one layer at a time. +func TestEscPeelsTheQueryThenCloses(t *testing.T) { + lab := newHomeLab(t) + mine := lab.session("-tmp-alpha", "aaaa000000000001", "one", "/tmp/alpha", time.Now()) + a := lab.app(mine) + a.openHome() + for _, r := range "abc" { + a.homeKey(key(string(r))) + } + a.homeKey(key("esc")) + if !a.at(pageHome) { + t.Fatal("the first esc left home instead of clearing the query") + } + if !a.home.box.empty() { + t.Fatalf("the box still holds %q", a.home.box.String()) + } + a.homeKey(key("esc")) + if a.at(pageHome) { + t.Fatal("the second esc did not close home") + } +} // ── the two columns ───────────────────────────────────────────────────────── @@ -1434,6 +1598,7 @@ func TestHomeMarksARowWhoseFolderIsGoneWhereverItsAddressIsDrawn(t *testing.T) { t.Fatalf("no %q on the drop-up's row:\n%s", homeGoneShort, homeText(a)) } a.homeKey(key("up")) + a.homeKey(key("up")) if got := a.home.focused().Transcript; got != gone { t.Fatalf("↑ landed on %q, want the row whose folder is gone", got) } @@ -1491,6 +1656,7 @@ func TestHomeLeavesARowWhoseFolderIsThereAlone(t *testing.T) { a.homeKey(key(string(r))) } a.homeKey(key("up")) + a.homeKey(key("up")) card := strings.Join(homeCardNow(t, a), "\n") for _, clause := range []string{"enter open", "ctrl+t new chat here", "ctrl+o open folder"} { if !strings.Contains(card, clause) { @@ -1836,9 +2002,9 @@ func TestTheWelcomeBoxRetiresWhenHomeLands(t *testing.T) { if !a.welcome.spent { t.Fatal("the welcome box was hidden rather than retired, so it can come back") } - a.closeHome() + a.homeKey(key("esc")) if a.at(pageHome) { - t.Fatal("close did not leave home") + t.Fatal("esc did not leave home") } if a.welcome.open { t.Fatalf("the welcome box appeared after home closed:\n%s", ansi.Strip(mustFrame(a))) @@ -1849,6 +2015,21 @@ func TestTheWelcomeBoxRetiresWhenHomeLands(t *testing.T) { } // esc drops into the conversation that was loaded underneath all along. +func TestEscFromTheLandingLandsInTheSession(t *testing.T) { + lab := newHomeLab(t) + now := time.Now() + mine := lab.session("-tmp-alpha", "aaaa000000000001", "the one the door picked", "/tmp/alpha", now) + lab.session("-tmp-alpha", "aaaa000000000002", "yesterday's chat", "/tmp/alpha", now.Add(-20*time.Hour)) + + a := lab.launch(mine, true) + a.homeKey(key("esc")) + if a.at(pageHome) { + t.Fatal("esc did not close the landing") + } + if a.file != mine { + t.Fatalf("esc changed the conversation to %q", a.file) + } +} // enter on the row the window is already in is the same door, and it says // nothing on the way through: the conversation is what happens next. @@ -2036,6 +2217,31 @@ func (l *homeLab) door(standing string) *app { } // TWO SPACES IN AN EMPTY BOX GO HOME. +func TestDoubleSpaceInAnEmptyBoxGoesHome(t *testing.T) { + lab := newHomeLab(t) + now := time.Now() + mine := lab.session("-tmp-alpha", "aaaa000000000001", "here", "/tmp/alpha", now) + lab.session("-tmp-alpha", "aaaa000000000002", "somewhere else", "/tmp/alpha", now.Add(-time.Hour)) + + a := lab.door(mine) + if !a.homeDoorOpen() { + t.Fatal("the door is shut on a machine with somewhere to go") + } + a.key(key(" ")) + if got := a.input.String(); got != " " { + t.Fatalf("the first space did not type itself: %q", got) + } + if a.at(pageHome) { + t.Fatal("one space opened home") + } + a.key(key(" ")) + if !a.at(pageHome) { + t.Fatal("two spaces did not open home") + } + if got := a.input.String(); got != "" { + t.Fatalf("the gesture left %q behind in the box", got) + } +} // …AND IT CANNOT EAT A SPACE SOMEBODY WANTED. The first one types itself and // stays typed unless the very next key is another space. @@ -2071,21 +2277,85 @@ func TestASingleSpaceThenALetterTypesNormally(t *testing.T) { // opening a line — so the two chords the steer wave taught left a NEWLINE in a // box that had nothing in it. Nothing on the screen changed: [editor.empty] // calls a whitespace-only draft empty, so the foot went on advertising -// `esc back`, and the gesture — which asked for exactly one space and +// `space space home`, and the gesture — which asked for exactly one space and // found "\n " — never fired again. Worse, [writeDraft] kept that draft on disk // and the next window on the directory ADOPTED it, so the door stayed dead // across restarts. +func TestDoubleSpaceGoesHomeFromABoxThatShowsNothingButHoldsANewline(t *testing.T) { + lab := newHomeLab(t) + now := time.Now() + mine := lab.session("-tmp-alpha", "aaaa000000000001", "here", "/tmp/alpha", now) + lab.session("-tmp-alpha", "aaaa000000000002", "somewhere else", "/tmp/alpha", now.Add(-time.Hour)) + + a := lab.door(mine) + // ctrl+j is the key a terminal sends for both of the enter chords it cannot + // spell, and over an empty box it opens a line. + a.key(key("ctrl+j")) + if got := a.input.String(); got != "\n" { + t.Fatalf("ctrl+j left %q in the box, want a newline", got) + } + if !a.homeDoorShowing() { + t.Fatal("the foot stopped advertising the door, so the test is no longer about the bug") + } + a.key(key(" ")) + a.key(key(" ")) + if !a.at(pageHome) { + t.Fatalf("two spaces did not open home from a box holding %q", a.input.String()) + } + if got := a.input.String(); got != "" { + t.Fatalf("the gesture left %q behind in the box", got) + } +} // AND THE LAW IN ONE SENTENCE: WHEREVER THE DOOR IS ADVERTISED, TWO SPACES OPEN // IT. The advertisement and the gesture used to ask different questions about // the same box — one whitespace-insensitive, one demanding exactly one space — // and every draft the two disagreed about was a door drawn over a gesture that // could not fire. +func TestEveryBoxTheFootCallsEmptyAnswersTheDoubleSpace(t *testing.T) { + for _, held := range []string{"", " ", " ", "\n", "\n\n", "\n ", " \n", "\t"} { + lab := newHomeLab(t) + now := time.Now() + mine := lab.session("-tmp-alpha", "aaaa000000000001", "here", "/tmp/alpha", now) + lab.session("-tmp-alpha", "aaaa000000000002", "elsewhere", "/tmp/alpha", now.Add(-time.Hour)) + + a := lab.door(mine) + a.input.setText(held) + if !a.homeDoorShowing() { + t.Fatalf("a box holding %q is not advertising the door", held) + } + a.key(key(" ")) + a.key(key(" ")) + if !a.at(pageHome) { + t.Errorf("a box holding %q advertised the door and refused the gesture", held) + } + } +} // AND THE CARET IS WHAT "THE SPACE YOU JUST TYPED" MEANS. A space typed at the // FRONT of a box holding a newline is behind the caret exactly as one typed at // the back is, so the gesture fires either way — it is the same two keystrokes // against the same blank-looking box. +func TestTheGestureReadsTheSpaceBehindTheCaretAndNotTheEndOfTheDraft(t *testing.T) { + lab := newHomeLab(t) + now := time.Now() + mine := lab.session("-tmp-alpha", "aaaa000000000001", "here", "/tmp/alpha", now) + lab.session("-tmp-alpha", "aaaa000000000002", "elsewhere", "/tmp/alpha", now.Add(-time.Hour)) + + a := lab.door(mine) + a.input.setText("\n") + // Straight onto the caret: [editor.home] is line-relative, and the line this + // draft ends on is the empty one after the break. + a.input.cursor = 0 + a.key(key(" ")) + if got := a.input.String(); got != " \n" { + t.Fatalf("the first space landed as %q", got) + } + a.key(key(" ")) + if !a.at(pageHome) { + t.Fatal("two spaces at the front of a blank-looking box did not open home") + } +} // AND A DRAFT WITH WORDS IN IT IS STILL A DRAFT. The widened gesture may not // reach past the one thing it was always forbidden to touch: a sentence. @@ -2136,7 +2406,8 @@ func TestAPasteWhileHomeIsOpenLandsInHomesBox(t *testing.T) { lab.session("-tmp-alpha", "aaaa000000000002", "somewhere else", "/tmp/alpha", now.Add(-time.Hour)) a := lab.door(mine) - a.key(key("esc")) + a.key(key(" ")) + a.key(key(" ")) if !a.at(pageHome) { t.Fatal("home did not open") } @@ -2159,7 +2430,8 @@ func TestHomesBoxWrapsALongDraftInsteadOfTruncatingIt(t *testing.T) { lab.session("-tmp-alpha", "aaaa000000000002", "somewhere else", "/tmp/alpha", now.Add(-time.Hour)) a := lab.door(mine) - a.key(key("esc")) + a.key(key(" ")) + a.key(key(" ")) if !a.at(pageHome) { t.Fatal("home did not open") } @@ -2186,7 +2458,8 @@ func TestTheDoorIsOpenWithOnlyThisConversation(t *testing.T) { if got := a.footHint(a.width); got != microcopy+" · "+homeDoorWord { t.Fatalf("the hint slot reads %q on a one-conversation machine", got) } - a.key(key("esc")) + a.key(key(" ")) + a.key(key(" ")) if !a.at(pageHome) { t.Fatal("two spaces did not open home with only this conversation") } @@ -2220,7 +2493,8 @@ func TestTheDoorIsOpenOnAMachineThatHoldsNothing(t *testing.T) { if got := a.footHint(a.width); got != microcopy+" · "+homeDoorWord { t.Fatalf("the hint slot reads %q on an empty machine", got) } - a.key(key("esc")) + a.key(key(" ")) + a.key(key(" ")) if !a.at(pageHome) { t.Fatal("two spaces did not open home on an empty machine") } @@ -2286,7 +2560,7 @@ func TestAnEmptyHomeKeepsItsShapeAtEveryWidth(t *testing.T) { for _, r := range "pricing" { drive(t, a, key(string(r))) } - if a.home.box.String() != "pricing" || a.home.picked { + if !strings.Contains(homeText(a), homeStartWord+`: "pricing"`) { t.Fatalf("at %d columns typing on an empty home does not offer a new conversation:\n%s", tc.width, homeText(a)) } } @@ -2399,7 +2673,8 @@ func TestTheDoorOpensWhenThisWindowStartsASecondConversation(t *testing.T) { if !a.homeDoorShowing() { t.Fatal("the door works and is not advertised") } - a.key(key("esc")) + a.key(key(" ")) + a.key(key(" ")) if !a.at(pageHome) { t.Fatal("the gesture did not open home") } @@ -2427,7 +2702,8 @@ func TestAReadingOfNothingDoesNotShutTheDoor(t *testing.T) { if !a.homeDoorOpen() { t.Fatal("the door is shut after home closed on a reading of nothing") } - a.key(key("esc")) + a.key(key(" ")) + a.key(key(" ")) if !a.at(pageHome) { t.Fatal("the gesture did not open home") } @@ -2486,10 +2762,10 @@ func TestTheDoorIsAdvertisedWhileIdleAndEmpty(t *testing.T) { } a.key(key("h")) - if !a.homeDoorShowing() { - t.Fatal("the back hint disappeared while typing") + if a.homeDoorShowing() { + t.Fatal("the door is still advertised while something is being typed") } - if got := a.footHint(a.width); got != microcopy+" · "+homeDoorWord { + if got := a.footHint(a.width); got != microcopy { t.Fatalf("the slot reads %q while typing", got) } } @@ -2519,11 +2795,9 @@ func TestClickingTheDoorGoesHome(t *testing.T) { if row < 0 { t.Fatal("no keys row on the frame") } - cmd, took := a.homeDoorPress(a.homeDoor.from, row) - if !took { + if _, took := a.homeDoorPress(a.homeDoor.from, row); !took { t.Fatal("a click on the door did nothing") } - drive(t, a, runCmd(cmd)...) if !a.at(pageHome) { t.Fatal("the click did not open home") } @@ -2536,7 +2810,7 @@ func TestClickingTheDoorGoesHome(t *testing.T) { } } -// The round trip is Home, Enter on a conversation, then Escape back to Home. +// THE ROUND TRIP: home → enter → the conversation → space space → home. func TestTheDoorAndHomeBounceBackAndForth(t *testing.T) { lab := newHomeLab(t) now := time.Now() @@ -2557,13 +2831,14 @@ func TestTheDoorAndHomeBounceBackAndForth(t *testing.T) { if a.file != mine { t.Fatalf("enter landed in %q", a.file) } - a.key(key("esc")) + a.key(key(" ")) + a.key(key(" ")) if !a.at(pageHome) { t.Fatal("the gesture did not go back home") } a.homeKey(key("esc")) - if !a.at(pageHome) || a.file != mine { - t.Fatal("esc left the home destination") + if a.at(pageHome) || a.file != mine { + t.Fatal("esc did not come back to the conversation") } } @@ -2576,7 +2851,8 @@ func TestTheGestureWorksWhileATurnIsRunning(t *testing.T) { a := lab.door(mine) a.state = stateWorking - a.key(key("esc")) + a.key(key(" ")) + a.key(key(" ")) if !a.at(pageHome) { t.Fatal("the gesture did not work with a turn running") } @@ -3158,10 +3434,55 @@ func driveToPlace(t *testing.T, lab *homeLab, where page) (*app, *editor) { // space types itself into the place's own filter, exactly as it does into a // conversation's draft, and the second opens home and leaves nothing behind // in the box. +func TestDoubleSpaceFromEveryTypingPlaceGoesHome(t *testing.T) { + for _, where := range []page{pageTasks, pageMemory, pageSearch} { + lab := newHomeLab(t) + a, box := driveToPlace(t, lab, where) + if box == nil { + t.Fatalf("%v has no box to type into", where) + } + a.key(key(" ")) + if got := box.String(); got != " " { + t.Fatalf("%v: the first space did not type itself: %q", where, got) + } + if a.at(pageHome) { + t.Fatalf("%v: one space opened home", where) + } + a.key(key(" ")) + if !a.at(pageHome) { + t.Fatalf("%v: two spaces did not open home", where) + } + if got := box.String(); got != "" { + t.Fatalf("%v: the gesture left %q behind in the box", where, got) + } + } +} // AND FROM A PLACE WITH NO BOX AT ALL — spend, standing — TWO BARE SPACES GO // HOME, and a letter between them disarms the door (placekeys.go's // [app.placeHomeGesture]). +func TestDoubleSpaceFromABoxlessPlaceGoesHome(t *testing.T) { + for _, where := range []page{pageSpend, pageStanding} { + lab := newHomeLab(t) + a, box := driveToPlace(t, lab, where) + if box != nil { + t.Fatalf("%v has a box, and only home starts things", where) + } + a.key(key(" ")) + if a.at(pageHome) { + t.Fatalf("%v: one space opened home", where) + } + a.key(key("x")) + a.key(key(" ")) + if a.at(pageHome) { + t.Fatalf("%v: a letter between two spaces did not disarm the door", where) + } + a.key(key(" ")) + if !a.at(pageHome) { + t.Fatalf("%v: two spaces did not open home", where) + } + } +} // SPACE IS SETTINGS' OWN VERB, and the door loses to it: `activate` is what the // panel draws space meaning on every row, and a door that swallowed the key @@ -3347,3 +3668,18 @@ func TestSpaceInTheTaskRoomPagesTheCardAndDoesNotOpenHome(t *testing.T) { t.Fatalf("space left the record at %d and pgdown at %d; they are the same key here", paged, a.taskSheet.detailTop) } } + +// The ask-here row owns this choice; a retired command must not stay in the menu +// or make an ordinary mention act as a send tag. +func TestAskHereHasNoSlashCommand(t *testing.T) { + if knownCommand("ask") || commandDoor("ask") != sendDoorNone { + t.Fatal("/ask is still registered as a command or send tag") + } + for _, value := range []string{"/ask", "explain /ask this"} { + for _, span := range commandSpans([]rune(value), true) { + if string([]rune(value)[span.from:span.to]) == "/ask" { + t.Fatalf("%q still contains an active /ask tag", value) + } + } + } +} diff --git a/internal/tui3/homearrows_test.go b/internal/tui3/homearrows_test.go index 40fb5a11c0..33d8f80d6d 100644 --- a/internal/tui3/homearrows_test.go +++ b/internal/tui3/homearrows_test.go @@ -170,8 +170,8 @@ func TestAHeldRosterDoesNotTakeNarrowHomesKeys(t *testing.T) { } // And esc is home's own way out rather than the roster's. drive(t, a, key("esc")) - if !a.at(pageHome) { - t.Fatal("esc left Home for the hidden roster") + if a.at(pageHome) { + t.Fatal("esc handed the keyboard back to a roster instead of closing home") } } @@ -190,9 +190,7 @@ func TestAHeldRosterDoesNotTakeTheFollowUpEnterOnANarrowHome(t *testing.T) { a.railTake(true) a.openHome() typeHome(a, "remind me at 6") - a.home.box.setText("/ask " + a.home.box.String()) - a.home.build() - drive(t, a, key("enter")) + drive(t, a, key("up"), key("enter")) ex := theExchange(a) if ex == nil { diff --git a/internal/tui3/homeask.go b/internal/tui3/homeask.go deleted file mode 100644 index 5ddbba3467..0000000000 --- a/internal/tui3/homeask.go +++ /dev/null @@ -1,21 +0,0 @@ -package tui3 - -import ( - "strings" - - tea "charm.land/bubbletea/v2" -) - -// runAskCommand uses the existing home exchange door. The command stays in the -// box until there are words to ask, and a refusal leaves the draft editable. -func (a *app) runAskCommand(text string) tea.Cmd { - var shown tea.Cmd - if !a.at(pageHome) { - shown = a.showPage(pageHome) - } - text = strings.TrimSpace(text) - a.home.box.setText("/ask " + text) - a.home.picked = false - a.home.build() - return tea.Batch(shown, a.askHere(text)) -} diff --git a/internal/tui3/homeask_test.go b/internal/tui3/homeask_test.go deleted file mode 100644 index 43a6c83382..0000000000 --- a/internal/tui3/homeask_test.go +++ /dev/null @@ -1,119 +0,0 @@ -package tui3 - -import ( - "path/filepath" - "strings" - "testing" - "time" -) - -func TestAskCommandSelectsAnExchangeFromEitherComposer(t *testing.T) { - for _, home := range []bool{true, false} { - for _, line := range []string{"/ask explain this", "explain /ask this"} { - lab := newErrandLab(t) - mine := lab.session("-tmp-alpha", "aaaa000000000001", "pricing research", "/tmp/alpha", time.Now()) - a := lab.app(mine) - a.start = func(string) (Conversation, error) { - t.Fatal("/ask started a regular conversation") - return Conversation{}, nil - } - if home { - a.openHome() - typeHome(a, line) - } else { - a.closeHome() - a.input.setText(line) - } - drive(t, a, key("enter")) - if !a.at(pageHome) || theExchange(a) == nil { - t.Fatalf("%q, home=%v: no home exchange", line, home) - } - if len(lab.agent.sent) != 1 || lab.agent.sent[0] != "explain this" { - t.Fatalf("sent %v", lab.agent.sent) - } - if a.home.box.String() != "" { - t.Fatal("successful ask left the command in the box") - } - } - } -} - -func TestBareAskAndCompletionKeepTheQuestionEditable(t *testing.T) { - lab := newErrandLab(t) - mine := lab.session("-tmp-alpha", "aaaa000000000001", "pricing research", "/tmp/alpha", time.Now()) - a := lab.app(mine) - a.openHome() - typeHome(a, "/as") - drive(t, a, key("up"), key("enter")) - if a.home.box.String() != "/ask " || len(a.exchanges) != 0 { - t.Fatalf("completion produced %q", a.home.box.String()) - } - drive(t, a, key("enter")) - if a.home.box.String() != "/ask " || len(a.exchanges) != 0 { - t.Fatal("bare /ask must wait for the question") - } - typeHome(a, "explain this") - drive(t, a, key("enter")) - if len(lab.agent.sent) != 1 || lab.agent.sent[0] != "explain this" { - t.Fatalf("sent %v", lab.agent.sent) - } -} - -func TestAskRefusalPreservesTheDraftAndTray(t *testing.T) { - a, dir := homeDropLab(t, "server.log") - pasteText(t, a, filepath.Join(dir, "server.log")) - typeHome(a, "/ask explain this") - a.errand = nil - drive(t, a, key("enter")) - if a.home.box.String() != "/ask explain this" || theExchange(a) != nil || len(a.chips) != 1 { - t.Fatal("unavailable ask consumed the draft") - } - if !strings.Contains(homeText(a), homeAskUnavailableWord) { - t.Fatal("ask refusal was not visible") - } -} - -func TestHomeSubmissionStaysUnselectedAcrossResizes(t *testing.T) { - lab := newHomeLab(t) - mine := lab.session("-tmp-alpha", "aaaa000000000001", "pricing research", "/tmp/alpha", time.Now()) - a := lab.app(mine) - a.openHome() - typeHome(a, "pricing") - for _, width := range []int{180, 80, 45, 180} { - a.width = width - homeText(a) - if _, ok := a.home.focusedLine(); ok || a.home.picked { - t.Fatalf("redraw at %d selected a result", width) - } - drive(t, a, key("up")) - if a.home.focused().Transcript != mine { - t.Fatalf("first up at %d did not select the result", width) - } - drive(t, a, key("down")) - if _, ok := a.home.focusedLine(); ok || a.home.picked { - t.Fatalf("down at %d did not return to composing", width) - } - } -} - -func TestRemovingAskRestoresNewConversationAndConflictingTagsKeepTheDraft(t *testing.T) { - lab := newErrandLab(t) - mine := lab.session("-tmp-alpha", "aaaa000000000001", "pricing research", "/tmp/alpha", time.Now()) - a := lab.app(mine) - a.openHome() - typeHome(a, "explain /ask /task this") - drive(t, a, key("enter")) - if a.home.box.String() != "explain /ask /task this" || len(lab.agent.sent) != 0 { - t.Fatal("conflicting tags consumed the draft") - } - next := &fakeAgent{model: "m"} - a.start = func(string) (Conversation, error) { - return Conversation{Agent: next, SessionFile: filepath.Join(t.TempDir(), "transcript.jsonl")}, nil - } - a.home.box.setText("explain this") - a.home.build() - drive(t, a, key("enter")) - if a.at(pageHome) || len(next.sent) != 1 || next.sent[0] != "explain this" || len(lab.agent.sent) != 0 { - t.Fatal("removing the tags did not restore a new conversation") - } -} diff --git a/internal/tui3/homeaskmd_test.go b/internal/tui3/homeaskmd_test.go index c1c79bd6df..ddf23603db 100644 --- a/internal/tui3/homeaskmd_test.go +++ b/internal/tui3/homeaskmd_test.go @@ -48,9 +48,7 @@ func askMarkdownLab(t *testing.T) *app { besideTheList(a) a.openHome() typeHome(a, "what is standing") - a.home.box.setText("/ask " + a.home.box.String()) - a.home.build() - drive(t, a, key("enter")) + drive(t, a, key("up"), key("enter")) return a } diff --git a/internal/tui3/homebullets_test.go b/internal/tui3/homebullets_test.go index 171d396eca..6cf06c04e9 100644 --- a/internal/tui3/homebullets_test.go +++ b/internal/tui3/homebullets_test.go @@ -8,19 +8,19 @@ import ( "github.com/Agent-Field/codeaf/internal/tui2/tokens" ) -func TestEscapeFooterNamesHomeAndMain(t *testing.T) { +func TestHomeFooterNamesDoubleSpaceOnlyWhereItWorks(t *testing.T) { a, _ := homeTabsFixture(t) a.closeHome() - if got := a.idleHint(); !strings.Contains(got, "esc home") { + if got := a.idleHint(); !strings.Contains(got, homeDoorWord) { t.Fatalf("conversation footer: %q", got) } - b := crumbApp(t) - if got := b.idleHint(); !strings.Contains(got, "esc main") || strings.Contains(got, "esc home") { - t.Fatalf("task footer: %q", got) + a.input.setText("draft") + if got := a.idleHint(); strings.Contains(got, homeDoorWord) { + t.Fatalf("draft advertises the empty-box gesture: %q", got) } - b.hintRow(b.width) - if b.homeDoor.to <= b.homeDoor.from { - t.Fatal("task Escape label has no mouse target") + b := crumbApp(t) + if got := b.idleHint(); strings.Contains(got, homeDoorWord) { + t.Fatalf("task room advertises a conversation-only gesture: %q", got) } } diff --git a/internal/tui3/homedrop_test.go b/internal/tui3/homedrop_test.go index 68c696add8..7280abd883 100644 --- a/internal/tui3/homedrop_test.go +++ b/internal/tui3/homedrop_test.go @@ -88,7 +88,7 @@ func TestDroppingAFileOnHomeLightsStartWithNothingTyped(t *testing.T) { t.Fatalf("chips are %v, want %v", chipNames(a), want) } line, ok := a.home.focusedLine() - if ok { + if !ok || line.kind != homeAction { t.Fatalf("the cursor rests on %v, want the action row", line.kind) } if !strings.Contains(homeText(a), "server.log") { diff --git a/internal/tui3/homeexchange.go b/internal/tui3/homeexchange.go index bf0746f70b..b3e80abc2d 100644 --- a/internal/tui3/homeexchange.go +++ b/internal/tui3/homeexchange.go @@ -151,7 +151,8 @@ const ( // homeExchangeRow is the row kind an exchange wears in the left column. // -// It is numbered outside the homeRowKind block to keep its identity separate. The +// IT IS DECLARED HERE FOR [homeAskHere]'S REASON and given the next value above +// it, so neither can collide with the iota block another lane is editing. The // name carries `Row` because [homeExchange] is the thing itself and this is its // line on the screen — two names for two objects that must not be confused. const homeExchangeRow homeRowKind = 201 @@ -166,6 +167,15 @@ const homeExchangeRow homeRowKind = 201 // starts to be a second transcript in a pane forty cells wide. const exchangeStripRows = 2 +// homeAskHere is the row kind of that second action row. +// +// IT IS DECLARED HERE AND NOT IN [homeRowKind]'s OWN BLOCK, on purpose: the +// iota block in home.go is being edited by another lane in the same wave, and a +// constant appended to it would be a conflict over a line that says nothing. +// The value is far above the block's last member so the two can never collide, +// and [homeLine.stop] and [app.homeEnter] name it the way they name the rest. +const homeAskHere homeRowKind = 200 + // exchangeKind is what one drawn line of the exchange is. type exchangeKind uint8 @@ -1277,7 +1287,14 @@ func (a *app) exchangeKey(ex *homeExchange, msg tea.KeyPressMsg) tea.Cmd { return nil case "esc": - // Leave the reply draft in place when returning to the list. + // ONE LAYER AT A TIME, home's own rule: a half-typed follow-up is + // cleared first and the second esc hands the keyboard back. NEITHER + // CLOSES THE EXCHANGE — it stands as a row on the column, which on a + // narrow frame is also how the list comes back over the stacked pane. + if !ex.box.empty() { + ex.box.reset() + return nil + } ex.focused, ex.onOffer, ex.changing = false, false, false return nil @@ -2148,7 +2165,7 @@ func exchangeAnswerWords(q session.Question) string { // does, and `continue as a conversation` when the first reply has landed. func exchangeHint(ex *homeExchange) string { if ex.changing { - return homeAskChangeWord + " · esc back" + return homeAskChangeWord + " · esc clear" } var parts []string if ex.asking() { diff --git a/internal/tui3/homeexchange_test.go b/internal/tui3/homeexchange_test.go index 70ecc32f6d..850fdfd7c3 100644 --- a/internal/tui3/homeexchange_test.go +++ b/internal/tui3/homeexchange_test.go @@ -189,33 +189,67 @@ func typeHome(a *app, text string) { // which is the cheapest keystroke on the screen after enter itself — and // `start a new conversation` keeps the rest, so nothing a person already knew // how to do changed meaning. -func TestHomeSubmissionRowsAndHintsAreAbsent(t *testing.T) { +func TestTypingAtHomeOffersAskHereDirectlyAboveStartingAConversation(t *testing.T) { lab := newErrandLab(t) - mine := lab.session("-tmp-alpha", "aaaa000000000001", "pricing research", "/tmp/alpha", time.Now()) + now := time.Now() + mine := lab.session("-tmp-alpha", "aaaa000000000001", "pricing research", "/tmp/alpha", now) + a := lab.app(mine) a.openHome() typeHome(a, "remind me at 6") - if _, ok := a.home.focusedLine(); ok { - t.Fatal("composer selected a result") + + ask, start := -1, -1 + for at, line := range a.home.lines { + switch line.kind { + case homeAskHere: + ask = at + case homeAction: + start = at + } + } + if ask < 0 || start < 0 { + t.Fatalf("both rows should be on the list, got ask=%d start=%d", ask, start) + } + if start != ask+1 { + t.Fatalf("`ask here` should sit directly above the action row, got ask=%d start=%d", ask, start) + } + if a.home.cursor != start { + t.Fatalf("the cursor should still rest on the action row, it is on line %d", a.home.cursor) } - if frame := homeText(a); strings.Contains(frame, "? ask here:") || strings.Contains(frame, homeStartWord) { - t.Fatalf("action row remains: %s", frame) + frame := homeText(a) + if !strings.Contains(frame, homeAskHereWord+`: "remind me at 6"`) { + t.Fatalf("the ask row does not quote the sentence:\n%s", frame) + } + if !strings.Contains(frame, homeStartWord+`: "remind me at 6"`) { + t.Fatalf("the action row is gone:\n%s", frame) + } + // The hint under the box names both readings of the same characters, and it + // names the ask by the ARROW that reaches it rather than by a chord most + // terminals cannot send (home.go's [app.homeHintWords] holds the argument). + if hint := errandRows(a)[len(errandRows(a))-1]; !strings.Contains(hint, "↑ ask here") || + !strings.Contains(hint, "enter starts a new conversation") { + t.Fatalf("the hint does not say both things enter can do:\n%s", hint) } - if hint := a.homeHintWords(); strings.Contains(hint, "ask here") || strings.Contains(hint, "starts a new conversation") { - t.Fatalf("mode hints remain: %s", hint) + if hint := errandRows(a)[len(errandRows(a))-1]; strings.Contains(hint, "ctrl+enter") { + t.Fatalf("the foot still advertises a chord most terminals cannot send:\n%s", hint) } } -// With no matches, up cannot select a submission mode or a nonexistent result. -func TestUpWithNoMatchesKeepsTheComposer(t *testing.T) { +// TestOneUpFromTheRestRowLandsOnAskHere pins the keystroke the row was placed +// for. A row that has to be walked to past a heading and a blank is a row +// nobody reaches. +func TestOneUpFromTheRestRowLandsOnAskHere(t *testing.T) { lab := newErrandLab(t) mine := lab.session("-tmp-alpha", "aaaa000000000001", "pricing research", "/tmp/alpha", time.Now()) + a := lab.app(mine) a.openHome() typeHome(a, "remind me at 6") a.homeKey(key("up")) - if _, ok := a.home.focusedLine(); ok { - t.Fatal("up selected a nonexistent result") + + line, ok := a.home.focusedLine() + if !ok || line.kind != homeAskHere { + t.Fatalf("↑ from the rest row should land on `ask here`, it landed on kind %d", line.kind) } } @@ -232,9 +266,7 @@ func TestAskHereMakesItsFolderOutsideTheProjectsAndSendsTheSentence(t *testing.T }) a.openHome() typeHome(a, "remind me at 6") - a.home.box.setText("/ask " + a.home.box.String()) - a.home.build() - drive(t, a, key("enter")) + drive(t, a, key("up"), key("enter")) if len(lab.agent.sent) != 1 || lab.agent.sent[0] != "remind me at 6" { t.Fatalf("the sentence should have gone to the errand agent, it sent %v", lab.agent.sent) @@ -345,9 +377,7 @@ func TestTheCardInThePaneIsAnsweredWithOne(t *testing.T) { besideTheList(a) a.openHome() typeHome(a, "remind me at 6 to leave") - a.home.box.setText("/ask " + a.home.box.String()) - a.home.build() - drive(t, a, key("enter")) + drive(t, a, key("up"), key("enter")) frame := homeText(a) for _, want := range []string{"remind me at 6 to leave", "at 6 today", "about $0.02, once", "1 yes, set it up"} { @@ -437,9 +467,7 @@ func TestTheErrandHintNamesOnlyTheAnswersTheCardDrew(t *testing.T) { besideTheList(a) a.openHome() typeHome(a, c.item.Words) - a.home.box.setText("/ask " + a.home.box.String()) - a.home.build() - drive(t, a, key("enter")) + drive(t, a, key("up"), key("enter")) ex := theExchange(a) if ex == nil || ex.view == nil { @@ -503,9 +531,7 @@ func TestTheCardInThePaneIsDeclinedWithZero(t *testing.T) { besideTheList(a) a.openHome() typeHome(a, "remind me at 6 to leave") - a.home.box.setText("/ask " + a.home.box.String()) - a.home.build() - drive(t, a, key("enter")) + drive(t, a, key("up"), key("enter")) // THE HINT NAMES IT, because this is the only way out of the question that // answers it. @@ -554,9 +580,7 @@ func TestContinueAsAConversationMovesTheFolderIntoTheBucket(t *testing.T) { }) a.openHome() typeHome(a, "what did we decide about pricing") - a.home.box.setText("/ask " + a.home.box.String()) - a.home.build() - drive(t, a, key("enter")) + drive(t, a, key("up"), key("enter")) made := lab.dirs[0] id := filepath.Base(made) @@ -625,9 +649,7 @@ func TestStandingUpMovesTheExchangeUnderTheItemItMade(t *testing.T) { }) a.openHome() typeHome(a, "remind me at 6 to leave") - a.home.box.setText("/ask " + a.home.box.String()) - a.home.build() - drive(t, a, key("enter")) + drive(t, a, key("up"), key("enter")) made := lab.dirs[0] store, err := standing.Open(lab.standing) @@ -704,9 +726,7 @@ func TestSomethingStandingKeepsItsCardAndHandsBackTheKeyboard(t *testing.T) { besideTheList(a) a.openHome() typeHome(a, "remind me at 6 to leave") - a.home.box.setText("/ask " + a.home.box.String()) - a.home.build() - drive(t, a, key("enter")) + drive(t, a, key("up"), key("enter")) ex := theExchange(a) if ex == nil || ex.view == nil { @@ -750,9 +770,7 @@ func TestEscLeavesTheExchangeAliveAndTheListMoving(t *testing.T) { besideTheList(a) a.openHome() typeHome(a, "remind me at 6") - a.home.box.setText("/ask " + a.home.box.String()) - a.home.build() - drive(t, a, key("enter")) + drive(t, a, key("up"), key("enter")) drive(t, a, key("esc")) if theExchange(a) == nil { @@ -808,9 +826,7 @@ func exchangeLab(t *testing.T) (*errandLab, *app) { besideTheList(a) a.openHome() typeHome(a, "remind me at 6") - a.home.box.setText("/ask " + a.home.box.String()) - a.home.build() - drive(t, a, key("enter")) + drive(t, a, key("up"), key("enter")) return lab, a } @@ -937,9 +953,7 @@ func TestSayingYesHandsTheKeyboardBackToTheList(t *testing.T) { // THE TWO DRIVES ARE THE POINT. [drive] queues what a command produced // behind the keys already in hand, so a `1` sent in the same call would be // pressed before the card it answers had arrived. - a.home.box.setText("/ask " + a.home.box.String()) - a.home.build() - drive(t, a, key("enter")) + drive(t, a, key("up"), key("enter")) drive(t, a, key("1")) ex := theExchange(a) @@ -1063,9 +1077,7 @@ func TestAChangedCardIsReplacedByTheOneThatFollowsIt(t *testing.T) { a := lab.app(mine, []session.Event{standingProposal(7, "remind me at 6 to leave"), {Kind: session.EventTurnDone}}) a.openHome() typeHome(a, "remind me at 6 to leave") - a.home.box.setText("/ask " + a.home.box.String()) - a.home.build() - drive(t, a, key("enter")) + drive(t, a, key("up"), key("enter")) drive(t, a, key(questionCommentKey)) ex := theExchange(a) @@ -1121,7 +1133,8 @@ func askedHere(t *testing.T, lab *errandLab, a *app, said string) *homeExchange // into HOME's box, and after the first one the pane has the hand — which is // exactly what a person does with tab or esc before typing again. a.homeTakeList() - typeHome(a, "/ask "+said) + typeHome(a, said) + a.homeKey(key("up")) a.homeKey(key("enter")) ex := theExchange(a) if ex == nil { @@ -1468,9 +1481,7 @@ func TestAFollowUpGetsTheSameSignalAsTheFirstTurn(t *testing.T) { a.clock = func() time.Time { return at } a.openHome() typeHome(a, "remind me at 6") - a.home.box.setText("/ask " + a.home.box.String()) - a.home.build() - drive(t, a, key("enter")) + drive(t, a, key("up"), key("enter")) ex := theExchange(a) if !ex.over() { diff --git a/internal/tui3/homegrid.go b/internal/tui3/homegrid.go index dbdd778b20..8b1efa1e8c 100644 --- a/internal/tui3/homegrid.go +++ b/internal/tui3/homegrid.go @@ -1535,7 +1535,7 @@ func (a *app) refreshGridReadings(now time.Time) tea.Cmd { // homePreselect puts the cursor on THE CONVERSATION THIS WINDOW WAS IN BEFORE // THIS ONE (law 6): the most recent key on this window's own stack that is not // the one in front and is on the grid. Enter is then a switch in two keys, and -// repeated Escape presses stay on Home. +// Escape returns to the conversation behind Home. func (a *app) homePreselect() { if !a.home.gridOn() { return diff --git a/internal/tui3/homephone.go b/internal/tui3/homephone.go index bcfe31712c..ac3cb24754 100644 --- a/internal/tui3/homephone.go +++ b/internal/tui3/homephone.go @@ -81,7 +81,7 @@ import ( ) // The row kinds the inbox adds, declared HERE and given values far above the -// iota block in home.go to keep its identity separate: that block is being edited +// iota block in home.go for [homeAskHere]'s reason: that block is being edited // by other lanes in the same wave, and a constant appended to it would be a // conflict over a line that says nothing. const ( @@ -553,8 +553,10 @@ func (a *app) homePhoneFrame(width, height int) ([]string, []int, int, int) { // homePhoneHead is the one row at the top: what this is, and the way out. func (a *app) homePhoneHead(width int, pal palette) string { head := " " + pal.bold(pal.ink("home")) - if gap := width - ansi.StringWidth(head) - 1; gap > 0 { - head += strings.Repeat(" ", gap) + escape := pal.dim("esc close") + if ansi.StringWidth(head)+ansi.StringWidth(escape)+2 <= width { + gap := width - ansi.StringWidth(head) - ansi.StringWidth(escape) - 1 + head += strings.Repeat(" ", gap) + escape } return head } @@ -712,6 +714,25 @@ func (a *app) homePhoneWords(line homeLine, pal palette) (string, string, noteIn case homeProject: return homeFoldMark(line.folded, pal) + " " + line.project, h.projectNote(line.proj, h.world.Read, pal.ascii), h.projectInk(line.proj) + case homeAskHere: + label := homeAskHereWord + if text := strings.TrimSpace(h.box.String()); text != "" { + label += ": " + text + } + return homeAskHereGlyph + " " + label, "", nil + case homeAction: + label := homeStartWord + if text := strings.TrimSpace(h.box.String()); text != "" { + // A COMMAND IS SAID THE SAME WAY IN BOTH COLUMNS. Enter dispatches a + // "/" line here exactly as it does on a wide frame ([app.homeEnter] is + // the one router), so the clause comes from the one place it is + // spelled ([homeView.runLabel]) rather than being written again narrower. + if word := h.runLabel(text); word != "" { + return homeStartGlyph + " " + word, "", nil + } + label += ": " + text + } + return homeStartGlyph + " " + label, "", nil } return "", "", nil } @@ -831,7 +852,8 @@ func phoneBar(width int, words []string, pal palette) (string, []hudSpan) { return out.String(), spans } -// homeInboxBar opens the selected result. Submission modes belong to the box. +// homeInboxBar is the bar over the list: open what the cursor is on, start +// something new, ask the box here. func (a *app) homeInboxBar() []homeBarTarget { var targets []homeBarTarget if a.home.box.empty() { @@ -839,6 +861,13 @@ func (a *app) homeInboxBar() []homeBarTarget { return a.homeKey(tea.KeyPressMsg{Code: tea.KeyRight}) }}) } + if !a.home.box.empty() { + return []homeBarTarget{ + {word: "open", do: func(a *app) tea.Cmd { return a.homeEnter() }}, + {word: "new", do: func(a *app) tea.Cmd { return a.homeStart(strings.TrimSpace(a.home.box.String())) }}, + {word: homeAskHereWord, do: func(a *app) tea.Cmd { return a.askHere(strings.TrimSpace(a.home.box.String())) }}, + } + } return append(targets, []homeBarTarget{ {word: "open", do: func(a *app) tea.Cmd { return a.homeEnter() }}, }...) @@ -908,7 +937,9 @@ func (a *app) homePhonePress(x, y int) tea.Cmd { return nil } a.home.cursor = at - a.home.picked = true + if line.kind != homeAction { + a.home.picked = true + } a.touch() return a.homeEnter() } diff --git a/internal/tui3/homephone_test.go b/internal/tui3/homephone_test.go index bb3a1afed3..9212490cfa 100644 --- a/internal/tui3/homephone_test.go +++ b/internal/tui3/homephone_test.go @@ -180,8 +180,8 @@ func TestTypingOnAPhoneSearchesWithNoSections(t *testing.T) { if strings.Contains(text, homePhoneWaitingWord+"\n") && strings.Contains(text, homePhoneNewsWord) { t.Fatalf("a search kept the sections:\n%s", text) } - if strings.Contains(text, homeStartWord) || strings.Contains(text, "? ask here:") { - t.Fatalf("removed action rows remain at the foot:\n%s", text) + if !strings.Contains(text, homeStartWord) || !strings.Contains(text, homeAskHereWord) { + t.Fatalf("the action rows are not at the foot:\n%s", text) } } @@ -401,7 +401,7 @@ func TestTheTaskRecordsFootIsBandsOnAPhone(t *testing.T) { // ── the action bar ────────────────────────────────────────────────────────── -func TestThePhoneInboxBarDoesNotOfferSubmissionModes(t *testing.T) { +func TestThePhoneActionBarIsThreeTargets(t *testing.T) { lab := newHomeLab(t) mine := lab.session("-tmp-alpha", "aaaa000000000001", "port the picker", "/tmp/alpha", time.Now()) a := phoneHome(t, lab, mine) @@ -603,9 +603,7 @@ func TestAnErrandOnAPhoneIsTheSameSheet(t *testing.T) { a.width, a.height = 50, 30 a.openHome() typeHome(a, "remind me at 6") - a.home.box.setText("/ask " + a.home.box.String()) - a.home.build() - drive(t, a, key("enter")) + drive(t, a, key("up"), key("enter")) ex := theExchange(a) if ex == nil { diff --git a/internal/tui3/homeprojectpaste_test.go b/internal/tui3/homeprojectpaste_test.go index 1cd31039ae..56837bc30d 100644 --- a/internal/tui3/homeprojectpaste_test.go +++ b/internal/tui3/homeprojectpaste_test.go @@ -2,6 +2,7 @@ package tui3 import ( "path/filepath" + "strings" "testing" ) @@ -16,7 +17,7 @@ func TestHomeFolderPasteOffersOneEnterToStartThere(t *testing.T) { pasteText(t, a, dir) for i := 0; i < 3; i++ { a.home.build() - if a.home.pastedProject() != dir { + if a.home.startLabel() != homeStartWord+" in "+dir { t.Fatal("the offer did not survive an idle rebuild") } } @@ -32,13 +33,13 @@ func TestAnyOtherKeyDismissesTheHomeFolderPasteOffer(t *testing.T) { a, dir := homeDropLab(t) pasteText(t, a, dir) drive(t, a, key(press)) - if a.home.pastedProject() != "" { + if a.home.pastedProject() != "" || strings.HasPrefix(a.home.startLabel(), homeStartWord+" in ") { t.Fatal("a non-Enter key kept the folder offer active") } // Returning to the same text cannot infer a new offer from it. a.home.box.setText(dir) a.home.build() - if a.home.pastedProject() != "" { + if strings.HasPrefix(a.home.startLabel(), homeStartWord+" in ") { t.Fatal("the old path reactivated the offer") } }) @@ -70,7 +71,7 @@ func TestDismissedFolderPasteSendsTheEditedTextAtTheChosenProject(t *testing.T) drive(t, a, key(" ")) pasteText(t, a, "explain this project") want := dir + " explain this project" - if got := a.home.pastedProject(); got != "" { + if got := a.home.startLabel(); !strings.HasPrefix(got, homeStartWord+": ") { t.Fatalf("edited folder text was not an ordinary message: %q", got) } drive(t, a, key("enter")) @@ -97,7 +98,7 @@ func TestHomeFolderPasteRearmsOnlyAfterClearingAndPasting(t *testing.T) { func TestTypingAFolderPathDoesNotOfferAProject(t *testing.T) { a, dir := homeDropLab(t) typeHome(a, dir) - if a.home.pastedProject() != "" { + if a.home.pastedProject() != "" || strings.HasPrefix(a.home.startLabel(), homeStartWord+" in ") { t.Fatal("typing a path activated the paste-only offer") } } diff --git a/internal/tui3/homequestionrows_test.go b/internal/tui3/homequestionrows_test.go index cb1a97b1b2..5083c8f59c 100644 --- a/internal/tui3/homequestionrows_test.go +++ b/internal/tui3/homequestionrows_test.go @@ -10,7 +10,7 @@ import ( "github.com/Agent-Field/codeaf/internal/tui2/tokens" ) -func TestTasksSpendAndSettingsAdvertiseEscapeHome(t *testing.T) { +func TestTasksSpendAndSettingsAdvertiseEscapeClose(t *testing.T) { for _, place := range everyPlaceTable() { if place.id != pageTasks && place.id != pageSpend && place.id != pageSettings { continue @@ -20,13 +20,13 @@ func TestTasksSpendAndSettingsAdvertiseEscapeHome(t *testing.T) { for _, width := range []int{180, 80, 44} { a.width = width rows := strings.Split(placeFrameText(a), "\n") - if last := rows[len(rows)-1]; !strings.Contains(last, "esc home") { + if last := rows[len(rows)-1]; !strings.Contains(last, "esc close") { t.Fatalf("at width %d, footer = %q", width, last) } } drive(t, a, key("esc")) - if !a.at(pageHome) { - t.Fatal("Escape did not return to Home") + if !a.at(pageNone) { + t.Fatal("Escape did not return to the conversation") } }) } diff --git a/internal/tui3/homeslash.go b/internal/tui3/homeslash.go index 55e98ef716..b118d03484 100644 --- a/internal/tui3/homeslash.go +++ b/internal/tui3/homeslash.go @@ -182,8 +182,6 @@ const ( func homeFate(word, rest string) string { rest = strings.TrimSpace(rest) switch canonicalCommand(strings.ToLower(strings.TrimPrefix(word, "/"))) { - case "ask": - return fateAnswers case "model": return fateTargetModel case "folder": @@ -280,9 +278,6 @@ func (a *app) homeSlash(line string) tea.Cmd { name, rest, _ := strings.Cut(strings.TrimPrefix(line, "/"), " ") rest = strings.TrimSpace(rest) word := canonicalCommand(strings.ToLower(name)) - if word == "ask" { - return a.runAskCommand(rest) - } h.box.reset() h.build() switch homeFate(word, rest) { diff --git a/internal/tui3/homeslash_test.go b/internal/tui3/homeslash_test.go index f7c882e728..7327c189f3 100644 --- a/internal/tui3/homeslash_test.go +++ b/internal/tui3/homeslash_test.go @@ -19,10 +19,7 @@ import ( // homeKindAt is the kind of the line the cursor rests on, for asserting where // the arrows landed without trusting the order the drop-up was built in. func homeKindAt(a *app) homeRowKind { - if line, ok := a.home.focusedLine(); ok { - return line.kind - } - return homeRowKind(255) + return a.home.lines[a.home.cursor].kind } // TestHomeSlashOffersCommandRows: typing a slash word offers the matching @@ -49,21 +46,21 @@ func TestHomeSlashOffersCommandRows(t *testing.T) { // "/clea" reaches /new through its clear alias, and the row that appears // must be the canonical one — the word this surface runs — with the alias // printed beside it, the same bargain chat's list makes. - a.homeKey(key("ctrl+u")) + a.homeKey(key("esc")) typeHome(a, "/clea") if text := homeText(a); !strings.Contains(text, "/new") { t.Fatalf("typing clea did not offer the canonical /new row:\n%s", text) } // "/mo" offers /model — the acceptance's own word. - a.homeKey(key("ctrl+u")) + a.homeKey(key("esc")) typeHome(a, "/mo") if text := homeText(a); !strings.Contains(text, "/model") { t.Fatalf("typing /mo did not offer the model command row:\n%s", text) } // "/conf" reaches /settings through its config alias. - a.homeKey(key("ctrl+u")) + a.homeKey(key("esc")) typeHome(a, "/conf") if text := homeText(a); !strings.Contains(text, "/settings") { t.Fatalf("typing /conf did not offer the canonical settings row:\n%s", text) @@ -438,8 +435,8 @@ func TestModelThenEscLeavesNoPickerOverTheConversation(t *testing.T) { } runCmd(a.key(key("esc"))) runCmd(a.key(key("esc"))) - if !a.at(pageHome) { - t.Fatal("two escapes left home") + if a.at(pageHome) { + t.Fatal("two escapes did not leave home") } if a.pick.open || a.target.pick.open { t.Fatal("a model list is standing over the conversation home was in front of") @@ -577,6 +574,7 @@ func TestHomeSlashSelectionSurvivesTheSlowTick(t *testing.T) { typeHome(a, "/set") a.homeKey(key("up")) + a.homeKey(key("up")) if k := homeKindAt(a); k != homeCommand { t.Fatalf("two ↑ landed on %v, want a command row", k) } @@ -629,14 +627,14 @@ func TestHomeOfferedPlaceSelectionSurvivesTheSlowTick(t *testing.T) { } } -// TestHomeSlashDoesNotAddASubmissionRow: the resting row and the foot under it +// TestHomeSlashActionRowSaysItWillRun: the resting row and the foot under it // both name what enter will actually do with a slash line. // // THE ROW MAY NOT PROMISE A CONVERSATION IT WILL NOT START. Enter on the action // row dispatches a "/" line ([app.homeEnter]), and the row went on reading // `+ start a new conversation: "/settings"` while it did — which is the one row // on this screen whose whole job is to say what the key means. -func TestHomeSlashDoesNotAddASubmissionRow(t *testing.T) { +func TestHomeSlashActionRowSaysItWillRun(t *testing.T) { lab := newHomeLab(t) mine := lab.session("-tmp-alpha", "aaaa000000000001", "porting the resume picker", "/tmp/alpha", time.Now()) a := lab.app(mine) @@ -648,13 +646,13 @@ func TestHomeSlashDoesNotAddASubmissionRow(t *testing.T) { t.Fatalf("the cursor left the action row onto %v", k) } text := homeText(a) - if strings.Contains(text, homeStartGlyph+" run /settings") { - t.Fatalf("a removed run-command action row was rendered:\n%s", text) + if !strings.Contains(text, "/settings") { + t.Fatalf("the action row does not say it will run the command:\n%s", text) } if strings.Contains(text, homeStartWord+`: "/settings"`) { t.Fatalf("the action row still offers to start a conversation with the command:\n%s", text) } - if hint := a.homeHintWords(); strings.Contains(hint, "enter runs this command") { + if hint := a.homeHintWords(); !strings.Contains(hint, "enter") { t.Fatalf("the foot reads %q, want it naming the run", hint) } @@ -662,10 +660,10 @@ func TestHomeSlashDoesNotAddASubmissionRow(t *testing.T) { // always said, quoted words and all. a.homeKey(key("esc")) typeHome(a, "pricing") - if text := homeText(a); strings.Contains(text, homeStartWord+`: "pricing"`) { - t.Fatalf("a sentence rendered a removed action row:\n%s", text) + if text := homeText(a); !strings.Contains(text, homeStartWord+`: "pricing"`) { + t.Fatalf("a sentence lost the row it has always had:\n%s", text) } - if hint := a.homeHintWords(); strings.Contains(hint, "enter starts a new conversation and sends this") { + if hint := a.homeHintWords(); !strings.Contains(hint, "enter starts a new conversation and sends this") { t.Fatalf("a sentence's foot reads %q", hint) } } @@ -688,7 +686,7 @@ func TestHomeSlashChosenRowWritesTheNameAndNotThePlaceholder(t *testing.T) { // the argless form, which runs and opens the picker instead, so the walk // looks for the row by what it is rather than counting keystrokes. for i := 0; i < len(a.home.lines); i++ { - if line, ok := a.home.focusedLine(); ok && line.kind == homeCommand && line.cmd.args != "" { + if line := a.home.lines[a.home.cursor]; line.kind == homeCommand && line.cmd.args != "" { break } a.homeKey(key("down")) @@ -751,7 +749,7 @@ func TestHomeSlashDoesNotSwallowAPastedPath(t *testing.T) { // The label itself rather than the painted row: a temp directory's path is // longer than the column, and this test is about which of the two readings // the row took, not about where it was cut. - if got := a.home.pastedProject(); got != dir { + if got := a.home.startLabel(); got != homeStartWord+" in "+dir { t.Fatalf("the action row says %q, want it offering the folder", got) } if hint := a.homeHintWords(); strings.Contains(hint, "enter runs this command") { diff --git a/internal/tui3/hop.go b/internal/tui3/hop.go index 60f7decc14..3caf404684 100644 --- a/internal/tui3/hop.go +++ b/internal/tui3/hop.go @@ -612,7 +612,7 @@ func (a *app) hopStripOrder(rows []hopRow) []hopRow { // The feature was invisible until you had learned the thing it exists for. // // THE WORLD IS READ ON THE KEYSTROKE, ONCE, and that is affordable for one -// reason: this gesture REPLACES pressing `esc`, which takes the same +// reason: this gesture REPLACES pressing `space space`, which takes the same // reading and then draws a whole page with it. It can be no slower than what a // person does today to answer the same question. func (a *app) hopRest(open []hopRow, now time.Time) []hopRow { diff --git a/internal/tui3/input.go b/internal/tui3/input.go index abda1dfd68..ce7d70a19e 100644 --- a/internal/tui3/input.go +++ b/internal/tui3/input.go @@ -584,14 +584,17 @@ func (a *app) key(msg tea.KeyPressMsg) tea.Cmd { // frees every printable key for the filter box and, on the card, for the // value somebody is typing into a field. // - // An agent-raised card can be deferred with Escape and resumed with - // /subharness; only an explicit answer resolves the offer. + // WITH ONE CARD THAT IS NOT OPENED BY A COMMAND: the intake chat itself + // raised. It comes through this same door because it is the same overlay, + // and the only thing that differs is what esc means on it — a NO, answered + // back to the turn that is waiting on it, rather than a way out of a page + // somebody opened to read ([app.answerSubharnessOffer]). if a.subPage.open && msg.String() != "ctrl+c" { return a.subPageKey(msg) } if msg.String() == "ctrl+c" { - // INTERRUPT FIRST. While a turn runs ctrl+c stops it — + // INTERRUPT FIRST. While a turn runs ctrl+c is the same key esc is — // a person hitting it mid-turn is reaching for the model, not for the // door, and every terminal habit in the world says that keystroke stops // the RUNNING thing. @@ -819,7 +822,17 @@ func (a *app) key(msg tea.KeyPressMsg) tea.Cmd { a.recallCancel() return nil } - return a.openHome() + // THE DOUBLE ESC IS THE REWIND'S DOOR, and it is read here rather than + // above the interrupt because the interrupt is not for sale (rewind.go): + // the first esc means exactly what it always meant and ARMS the mode on its + // way past, and only a second one inside the window is taken. A stray esc + // after the window has lapsed changes nothing. + cmd, taken := a.escRewind() + if taken { + return cmd + } + a.interrupt() + return cmd case "enter": if a.steerAvailable() { @@ -1292,6 +1305,16 @@ func (a *app) key(msg tea.KeyPressMsg) tea.Cmd { return a.syncLists() } + // TWO SPACES IN AN EMPTY BOX ARE THE DOOR HOME (home.go). It is read here, + // at the very bottom of the router, because it must lose to every other + // meaning a space could have on this surface — inside a paste bracket, in a + // filter box, in copy mode, in any overlay — and because the first of the + // two spaces has already typed itself perfectly ordinarily one keystroke + // ago, through the line below. + if a.homeGesture(msg) { + a.input.reset() + return tea.Batch(a.edited(), a.openHome()) + } if text := msg.Key().Text; text != "" { // The ordinary case: a key that carries text types it. // @@ -1479,8 +1502,6 @@ func (a *app) enterLine(marked bool) tea.Cmd { return a.openStanding() } return a.standingSayShown(tagWords, tagShown) - case sendDoorAsk: - return a.runAskCommand(tagWords) case sendDoorTask: return a.runTaskCommand(tagWords) } diff --git a/internal/tui3/notice.go b/internal/tui3/notice.go index fbe83d3490..27f904561d 100644 --- a/internal/tui3/notice.go +++ b/internal/tui3/notice.go @@ -212,7 +212,7 @@ var notices = []notice{ { id: "rewind-after-long-answer", slot: slotHint, priority: 70, armed: func(a *app) bool { return a.lastAnswerRunes() >= longAnswerRunes }, - text: "/rewind takes back an earlier message", + text: "esc esc takes back the last message", retire: eventRewound, }, { diff --git a/internal/tui3/notice_test.go b/internal/tui3/notice_test.go index 623392f98f..0314df44be 100644 --- a/internal/tui3/notice_test.go +++ b/internal/tui3/notice_test.go @@ -416,7 +416,7 @@ func TestAHintArmsDrawsLowestRetiresAndStaysRetired(t *testing.T) { // LOWEST RUNG. A running turn's own key outranks it, and so does a box with // words in it. a.state = stateWorking - if got := a.footHint(a.width); got != "ctrl+c interrupt" { + if got := a.footHint(a.width); got != "esc interrupt" { t.Fatalf("a tip outranked a running turn's key: %q", got) } a.state = stateIdle diff --git a/internal/tui3/pages.go b/internal/tui3/pages.go index 6f3d5d8a3c..48d882ad82 100644 --- a/internal/tui3/pages.go +++ b/internal/tui3/pages.go @@ -2273,12 +2273,7 @@ func (a *app) closeModals() { a.connPanel.close() a.harnPanel.close() a.permPanel.close() - // Navigation hides an unanswered offer without resolving or losing it. - if a.subPage.card.asked() { - a.subPage.open = false - } else { - a.subPage.close() - } + a.subPage.close() // AND HOME'S OWN MODEL LIST, which IS drawn where it stands and is still a // list nobody left open on purpose: walking to another place and back to a // list you had not finished with is a list you have to remember opening diff --git a/internal/tui3/pages_test.go b/internal/tui3/pages_test.go index 433945d20a..3b96075be0 100644 --- a/internal/tui3/pages_test.go +++ b/internal/tui3/pages_test.go @@ -527,14 +527,17 @@ func TestAPlaceOutranksEveryConversationTheWordsAlsoMatch(t *testing.T) { for _, r := range "sta" { drive(t, a, key(string(r))) } - place, lastChat := -1, -1 + place, lastChat, ask, action := -1, -1, -1, -1 for i, line := range a.home.lines { switch line.kind { case homePlace: place = i case homeSession: lastChat = i - + case homeAskHere: + ask = i + case homeAction: + action = i } } if place < 0 || lastChat < 0 { @@ -544,6 +547,11 @@ func TestAPlaceOutranksEveryConversationTheWordsAlsoMatch(t *testing.T) { t.Fatalf("the place is drawn above a conversation it outranks: place at %d, last chat at %d\n%s", place, lastChat, placeFrameText(a)) } + // AND THE TWO ROWS THAT ACT ON THE SENTENCE STAY UNDER IT. They are one + // cluster against the box and are not results at all ([homeAction]). + if ask < place || action < ask { + t.Fatalf("the sentence cluster moved: place %d, ask here %d, start %d", place, ask, action) + } // AND THE ROW SAYS WHAT IS BEHIND IT, not just what kind of thing it is. if !strings.Contains(placeFrameText(a), placeRowWord) { t.Fatalf("the offered place does not say what kind of thing it is:\n%s", placeFrameText(a)) @@ -770,9 +778,9 @@ func placeAt(id page) int { // seizing keys somebody has muscle memory for. The digits are the spare class. func TestTheNumbersOpenAPlaceFromTheConversationToo(t *testing.T) { a := placeApp(t) - a.closeHome() + drive(t, a, key("esc")) if a.at(pageHome) { - t.Fatal("close did not put the conversation back") + t.Fatal("esc did not put the conversation back") } drive(t, a, key("alt+2")) if a.page != pageTasks || !a.at(pageTasks) { @@ -784,7 +792,7 @@ func TestTheNumbersOpenAPlaceFromTheConversationToo(t *testing.T) { t.Fatalf("%s from the conversation landed on %q", placeChord(pageStanding), a.page.word()) } // AND `tab` IS STILL THE CONVERSATION'S OWN KEY THERE. - a.leavePlace() + drive(t, a, key("esc")) page := a.page drive(t, a, key("tab")) if a.page != page || a.at(pageTasks) || a.at(pageStanding) { diff --git a/internal/tui3/park.go b/internal/tui3/park.go index 14bd028d9c..d76a25f371 100644 --- a/internal/tui3/park.go +++ b/internal/tui3/park.go @@ -251,7 +251,7 @@ func (a *app) recallParkedAt(i int) bool { // same question the stop is, because a turn that is winding down has no // boundary left to steer into either. var parkedHint = []string{ - "waits for this answer", "ctrl+c stops and drops", steerArrowWord, "↑ or click to edit", + "waits for this answer", "esc stops and drops", steerArrowWord, "↑ or click to edit", } // parkedHeight is how many rows the block takes: the messages, then the one dim diff --git a/internal/tui3/park_test.go b/internal/tui3/park_test.go index eae7f3506f..eebfbb10d2 100644 --- a/internal/tui3/park_test.go +++ b/internal/tui3/park_test.go @@ -203,9 +203,9 @@ func TestTheParkedLineTrimsFromTheRightOnANarrowFrame(t *testing.T) { } } -// CTRL+C clears the parked queue at the keypress, before the interrupted stream +// ESC clears the parked queue at the keypress, before the interrupted stream // closes, so there is no inert waiting block left during teardown. -func TestCtrlCClearsTheParkedBlockWhileTheTurnIsWindingDown(t *testing.T) { +func TestEscClearsTheParkedBlockWhileTheTurnIsWindingDown(t *testing.T) { a, agent := streaming(t, "reading the tree. ") parkLine(t, a, "do much more of a deep research please") drive(t, a, frameMsg{}) @@ -220,13 +220,13 @@ func TestCtrlCClearsTheParkedBlockWhileTheTurnIsWindingDown(t *testing.T) { t.Fatalf("the hint slot never offered the stop: %q", got) } - drive(t, a, key("ctrl+c"), frameMsg{}) + drive(t, a, key("esc"), frameMsg{}) if !a.windingDown() { - t.Fatal("the surface is not winding down after ctrl+c") + t.Fatal("the surface is not winding down after esc") } if len(a.parks) != 0 { - t.Fatalf("ctrl+c left a message parked during teardown: %+v", a.parks) + t.Fatalf("esc left a message parked during teardown: %+v", a.parks) } body := plain(frame(a)) if strings.Contains(body, "do much more of a deep research please") || strings.Contains(body, parkedHint[0]) { @@ -236,7 +236,7 @@ func TestCtrlCClearsTheParkedBlockWhileTheTurnIsWindingDown(t *testing.T) { agent.finish() drive(t, a, streamClosedMsg{gen: a.gen}, frameMsg{}) if len(agent.sent) != 1 { - t.Fatalf("the stream close sent the message ctrl+c dropped: %q", agent.sent) + t.Fatalf("the stream close sent the message esc dropped: %q", agent.sent) } } @@ -293,39 +293,39 @@ func TestParkedMessagesGoOneAtATimeInTheOrderTheyWereTyped(t *testing.T) { } } -// ── ctrl+c ───────────────────────────────────────────────────────────────────── +// ── esc ───────────────────────────────────────────────────────────────────── -// CTRL+C WITH A MESSAGE WAITING STOPS THE ANSWER AND DROPS IT. -func TestCtrlCWithAMessageWaitingStopsTheAnswerAndDropsIt(t *testing.T) { +// ESC WITH A MESSAGE WAITING STOPS THE ANSWER AND DROPS IT. +func TestEscWithAMessageWaitingStopsTheAnswerAndDropsIt(t *testing.T) { a, agent := streaming(t, "reading the tree. ") parkLine(t, a, "no, the other file") - drive(t, a, key("ctrl+c")) + drive(t, a, key("esc")) agent.finish() drive(t, a, streamClosedMsg{gen: a.gen}) if agent.stops != 1 { - t.Fatalf("ctrl+c did not stop the answer: %d stops", agent.stops) + t.Fatalf("esc did not stop the answer: %d stops", agent.stops) } if len(agent.sent) != 1 { - t.Fatalf("ctrl+c sent the waiting message it should drop: %q", agent.sent) + t.Fatalf("esc sent the waiting message it should drop: %q", agent.sent) } if len(a.parks) != 0 { - t.Fatalf("ctrl+c left the waiting message behind: %+v", a.parks) + t.Fatalf("esc left the waiting message behind: %+v", a.parks) } } -// The issue's verification matrix names both queues. One ctrl+c clears the +// The issue's verification matrix names both queues. One esc clears the // session follow-up mirror and the editable parked queue, and neither stream // close may resurrect a turn from either one. -func TestCtrlCClearsBothWaitingQueuesWithoutAnOrphanedTurn(t *testing.T) { +func TestEscClearsBothWaitingQueuesWithoutAnOrphanedTurn(t *testing.T) { a, agent := streaming(t, "reading the tree. ") parkLine(t, a, "the parked message") a.follows = append(a.follows, queued{text: "the queued follow-up"}) - drive(t, a, key("ctrl+c")) + drive(t, a, key("esc")) if len(a.parks) != 0 || len(a.follows) != 0 { - t.Fatalf("ctrl+c left queues behind: parked=%+v queued=%+v", a.parks, a.follows) + t.Fatalf("esc left queues behind: parked=%+v queued=%+v", a.parks, a.follows) } agent.finish() drive(t, a, streamClosedMsg{gen: a.gen}) @@ -334,16 +334,16 @@ func TestCtrlCClearsBothWaitingQueuesWithoutAnOrphanedTurn(t *testing.T) { } } -// CTRL+C WITH NOTHING WAITING IS EXACTLY WHAT IT WAS: a stop, and no message. -func TestCtrlCWithNothingWaitingIsStillJustTheInterrupt(t *testing.T) { +// ESC WITH NOTHING WAITING IS EXACTLY WHAT IT WAS: a stop, and no message. +func TestEscWithNothingWaitingIsStillJustTheInterrupt(t *testing.T) { a, agent := streaming(t, "reading the tree. ") - drive(t, a, key("ctrl+c")) + drive(t, a, key("esc")) if agent.stops != 1 { - t.Fatalf("ctrl+c did not stop the answer: %d stops", agent.stops) + t.Fatalf("esc did not stop the answer: %d stops", agent.stops) } if len(agent.sent) != 1 { - t.Fatalf("ctrl+c sent something nobody typed: %q", agent.sent) + t.Fatalf("esc sent something nobody typed: %q", agent.sent) } if a.state != stateInterrupted { t.Fatalf("state = %v, want interrupted", a.state) @@ -351,15 +351,15 @@ func TestCtrlCWithNothingWaitingIsStillJustTheInterrupt(t *testing.T) { } // H4: the empty running line is the plain interrupt, while a parked message -// says that ctrl+c drops the waiting words as it stops. +// says that esc drops the waiting words as it stops. func TestTheHintSaysWhatEscDoesWhileAMessageIsWaiting(t *testing.T) { a, _ := streaming(t, "reading the tree. ") - if got := a.hintWord(); got != "ctrl+c interrupt" { + if got := a.hintWord(); got != "esc interrupt" { t.Fatalf("a plain running turn = %q, want the interrupt", got) } parkLine(t, a, "no, the other file") - if got := a.hintWord(); got != "ctrl+c stops and drops" { - t.Fatalf("hint = %q, want what ctrl+c now does", got) + if got := a.hintWord(); got != "esc stops and drops" { + t.Fatalf("hint = %q, want what esc now does", got) } } diff --git a/internal/tui3/pastechip_test.go b/internal/tui3/pastechip_test.go index 05edc57a7f..6b0bc6f3ac 100644 --- a/internal/tui3/pastechip_test.go +++ b/internal/tui3/pastechip_test.go @@ -44,7 +44,7 @@ func TestSmallAndSlashPastesStayTextAndBackspaceDropsAChip(t *testing.T) { if got := a.input.String(); got != "one\ntwo" { t.Fatalf("small paste became %q", got) } - a.input.setText("/ask ") + a.input.setText("/task ") a.paste("one\ntwo\nthree") if strings.Contains(a.input.String(), pasteTokenHead) { t.Fatalf("slash paste became a chip: %q", a.input.String()) diff --git a/internal/tui3/payload.go b/internal/tui3/payload.go index 58a7856fe1..1e09ddb958 100644 --- a/internal/tui3/payload.go +++ b/internal/tui3/payload.go @@ -226,7 +226,7 @@ func paintPayload(line string, facts []segment, pal palette, prose func(string) // // The legend's right end and the mode lines under it are written in one shape // at every call site that fills them (render.go's [app.hintWord] lists them -// all): `chord verb`, joined by " · ". `ctrl+c interrupt`. `y allow · n deny · a +// all): `chord verb`, joined by " · ". `esc interrupt`. `y allow · n deny · a // always`. `ctrl+r reveal · ctrl+y copy · esc`. THE CHORD IS THE PAYLOAD and // the verb is the prose, every time, which is a rule worth reading off the // string rather than making twenty-five call sites carry a list of their own diff --git a/internal/tui3/payload_test.go b/internal/tui3/payload_test.go index 726ca2feaf..e627f602de 100644 --- a/internal/tui3/payload_test.go +++ b/internal/tui3/payload_test.go @@ -243,7 +243,7 @@ func TestTheHintGrammarReadsEveryHintThisSurfaceWrites(t *testing.T) { } cases := []hintGrammarCase{ {"drag to select · any key ends it", nil}, - {"ctrl+c interrupt", []string{"ctrl+c"}}, + {"esc interrupt", []string{"esc"}}, {pickerKeysSwitch, []string{"enter", "esc"}}, // The picker's slot follows its cursor (palette.go's [picker.keysHint]). {pickerKeysModel, []string{"→", "alt+s", "enter", "ctrl+t", "esc"}}, @@ -273,8 +273,8 @@ func TestTheHintGrammarReadsEveryHintThisSurfaceWrites(t *testing.T) { // chip the card is actually drawing. {"a accept · n not right · s tell it · esc", []string{"a", "n", "s", "esc"}}, {"esc stops and sends", []string{"esc"}}, - {"opt+e effort · opt+a approvals · opt+k chats · / commands · esc back", - []string{"opt+e", "opt+a", "opt+k", "/", "esc"}}, + {"opt+e effort · opt+a approvals · opt+k chats · / commands · space space home", + []string{"opt+e", "opt+a", "opt+k", "/", "space", "space"}}, {"enter open where it was asked · p pause · s stop · n not here · esc", []string{"enter", "p", "s", "n", "esc"}}, {"enter open · ctrl+r reveal · ctrl+y copy · esc", @@ -297,7 +297,7 @@ func TestTheHintGrammarReadsEveryHintThisSurfaceWrites(t *testing.T) { } for _, prefix := range runPrefixes { for _, background := range []bool{false, true} { - for _, stop := range []string{"ctrl+c interrupt", parkedHint[1]} { + for _, stop := range []string{"esc interrupt", parkedHint[1]} { parts := []string{} want := append([]string(nil), prefix.want...) if prefix.hint != "" { diff --git a/internal/tui3/place_home.go b/internal/tui3/place_home.go index d55b928265..5e6037753c 100644 --- a/internal/tui3/place_home.go +++ b/internal/tui3/place_home.go @@ -536,8 +536,8 @@ func (placeHome) key(a *app, msg tea.KeyPressMsg) tea.Cmd { // // The phone tier's sheet over the inbox is the first (homesheet.go). The second // is a FOCUSED ERRAND: while it holds the keyboard, `tab` hands it back to the -// list; `esc` also returns there while preserving a half-typed follow-up. This -// is the zone law homeexchange.go states in full — a `tab` the router took first +// list and `esc` clears a half-typed follow-up before it does, which is the two- +// zone law homeexchange.go states in full — and a `tab` the router took first // would walk the person out of home mid-sentence. // // THE KEYBOARD IS SETTLED BEFORE THE KEY IS READ. An exchange holds it only diff --git a/internal/tui3/place_memory.go b/internal/tui3/place_memory.go index 133cf49093..cdcac06d83 100644 --- a/internal/tui3/place_memory.go +++ b/internal/tui3/place_memory.go @@ -601,7 +601,8 @@ func (a *app) memoryKey(msg tea.KeyPressMsg) tea.Cmd { a.touch() return nil } - return a.openHome() + a.leavePlace() + return nil case "enter": // ONE SPELLING OF WHAT `enter` DOES HERE, and it is the interface's // ([placeMemory.enter]). This arm held a second copy of it, which is how diff --git a/internal/tui3/place_search.go b/internal/tui3/place_search.go index 590c3e9f25..e4e1d62fbd 100644 --- a/internal/tui3/place_search.go +++ b/internal/tui3/place_search.go @@ -261,7 +261,8 @@ func (a *app) searchKey(msg tea.KeyPressMsg) tea.Cmd { a.touch() return a.searchAsked() } - return a.openHome() + a.leavePlace() + return nil case "up", "ctrl+p": a.moveSearch(-1) a.touch() diff --git a/internal/tui3/place_sessions.go b/internal/tui3/place_sessions.go index 5e5a558631..c2338c2da1 100644 --- a/internal/tui3/place_sessions.go +++ b/internal/tui3/place_sessions.go @@ -961,7 +961,8 @@ func (a *app) taskSheetKeyPress(msg tea.KeyPressMsg) (tea.Cmd, bool) { a.taskSheetTyped() return nil, true } - return a.openHome(), true + a.leavePlace() + return nil, true case taskSheetKey: // The chord that opened this is the chord that closes it — the roster's own // bargain with alt+t — and it closes it from inside a filter as well, @@ -1240,7 +1241,7 @@ func (a *app) taskSheetPress(x, y int) tea.Cmd { if y < 0 || y >= len(hits) { return nil } - // On a compact frame the foot is an `esc home` band, so a press + // On a compact frame the foot is an `esc close` band, so a press // on it is the way out (taskphone.go). if hits[y].kind == taskSheetHitBar { return a.taskSheetBarPress(x) @@ -1582,7 +1583,7 @@ func (p *tasksPlace) hint(a *app) string { // press. What is true there is the way out, and [placeTailed] puts `tab next // place` in front of it. if !p.detailOn && a.tasksFiltered().held == 0 { - return homeDoorWord + return mapCloseWords } var parts []string // THE CONVERSATION'S OWN CLAUSE, and it is the word this surface already uses @@ -1648,7 +1649,7 @@ func (a *app) tasksPageKeys(parts []string) []string { if a.taskSheetFiltering() { return append(parts, tasksClearFilterWord) } - return append(parts, tasksFilterHint, homeDoorWord) + return append(parts, tasksFilterHint, mapCloseWords) } func (a *app) taskSheetKeysLine() string { return a.taskSheet.hint(a) } diff --git a/internal/tui3/place_settings.go b/internal/tui3/place_settings.go index e2c75248e5..a7f99df572 100644 --- a/internal/tui3/place_settings.go +++ b/internal/tui3/place_settings.go @@ -180,14 +180,7 @@ func (placeSettings) note(a *app, width int) []string { return []string{" " + pal.dim(noteFit(a.sheet.footNote(), width-2))} } -func (placeSettings) hint(a *app) string { - hint := a.sheet.keysLine() - if !a.sheetLayerOwnsKeys() && !a.sheet.searching() && - !(a.sheet.onConnections() && (a.sheet.conn.armed || a.sheet.conn.expanded != "")) { - hint = strings.ReplaceAll(hint, "esc close", homeDoorWord) - } - return hint -} +func (placeSettings) hint(a *app) string { return a.sheet.keysLine() } // key is the panel's own grammar (settings.go's [app.sheetKey]): the value being // edited, the model picker, the section bar, and the search across all of them. diff --git a/internal/tui3/place_spend.go b/internal/tui3/place_spend.go index eb7f87c1a1..276420de87 100644 --- a/internal/tui3/place_spend.go +++ b/internal/tui3/place_spend.go @@ -512,7 +512,8 @@ func (a *app) spendKey(msg tea.KeyPressMsg) tea.Cmd { a.touch() return nil } - return a.openHome() + a.leavePlace() + return nil case "up", "ctrl+p": a.moveSpend(-1) a.touch() @@ -956,11 +957,11 @@ func (placeSpend) hint(a *app) string { } if len(parts) == 0 { // A PAGE WITH NOTHING ON IT STILL HAS A WAY OUT, and that is all it has. - // [placeTailed] adds `tab next place`, so this is `esc home` rather than + // [placeTailed] adds `tab next place`, so this is `esc close` rather than // a foot naming three keys over an empty ledger. - return homeDoorWord + return mapCloseWords } - return strings.Join(parts, railSep) + railSep + homeDoorWord + return strings.Join(parts, railSep) + railSep + mapCloseWords } func (placeSpend) press(a *app, y int) (tea.Cmd, bool) { diff --git a/internal/tui3/place_standing.go b/internal/tui3/place_standing.go index 897d1f4b17..90c76febca 100644 --- a/internal/tui3/place_standing.go +++ b/internal/tui3/place_standing.go @@ -697,7 +697,8 @@ func (a *app) standingPlaceKey(msg tea.KeyPressMsg) tea.Cmd { var cmd tea.Cmd switch msg.String() { case "esc": - return a.openHome() + a.leavePlace() + return nil // THE CURSOR WALKS THE ROWS THE BODY WAS PAINTED FROM, and there is one such // list ([app.standingPageRows]). Three arithmetics over three lists — one // clamping into the orders, one resolving against a shorter fold, one diff --git a/internal/tui3/place_tasks_test.go b/internal/tui3/place_tasks_test.go index 7158a6e59a..238beeed64 100644 --- a/internal/tui3/place_tasks_test.go +++ b/internal/tui3/place_tasks_test.go @@ -54,7 +54,7 @@ func TestTheTasksFootIsScreenOneEWordForWord(t *testing.T) { // sorting is a chord, and a chord nobody can find is a chord that does not // exist. The filter is named beside it because nothing else on the frame says // that a letter goes into the box on the control row rather than to the page. - const want = "enter open its room · → verbs: close, open folder, copy project, stop it · type to filter · esc home" + const want = "enter open its room · → verbs: close, open folder, copy project, stop it · type to filter · esc close" if got := a.taskSheetKeysLine(); got != want { t.Fatalf("the foot reads\n %q\nwant\n %q", got, want) } @@ -93,7 +93,7 @@ func TestTheTasksFootSaysOnlyWhatIsTrueOfTheRowUnderIt(t *testing.T) { if !ok || item.entry.Title != "Port the parser" { t.Fatalf("the walk did not reach the earlier conversation's row: %+v", item) } - const want = "enter go inside it · type to filter · esc home" + const want = "enter go inside it · type to filter · esc close" if got := a.taskSheetKeysLine(); got != want { t.Fatalf("over work another conversation ran the foot reads\n %q\nwant\n %q", got, want) } @@ -205,7 +205,7 @@ func TestTheTasksFootNamesNoStopWithoutTheEnginesDoor(t *testing.T) { // The cursor's row is a family, and [openTaskPlaceWithRows] has opened it — // so the fold clause is the `←` half. What this test is about is what is NOT // here: no stop verb, on a session with no door onto stopping. - const want = "enter open its room · ← fold it back up · → verbs: open folder, copy project · type to filter · esc home" + const want = "enter open its room · ← fold it back up · → verbs: open folder, copy project · type to filter · esc close" if got := a.taskSheetKeysLine(); got != want { t.Fatalf("the foot reads\n %q\nwant\n %q", got, want) } diff --git a/internal/tui3/placehint_test.go b/internal/tui3/placehint_test.go index 4051b7c713..66c25482cb 100644 --- a/internal/tui3/placehint_test.go +++ b/internal/tui3/placehint_test.go @@ -159,9 +159,9 @@ func TestTheSpendFootNamesTheKeysAPersonWouldPress(t *testing.T) { if strings.Contains(bare, spendEnterWord) { t.Errorf("the spend foot promises %q over a ledger with no rows: %q", spendEnterWord, bare) } - if bare != placeHintTail+" · esc home" { + if bare != placeHintTail+" · esc close" { t.Errorf("the spend foot over an empty ledger drew %q, want %q — the way out is said last and said once", - bare, placeHintTail+" · esc home") + bare, placeHintTail+" · esc close") } // A row that opens something: enter, the one verb, and the window. diff --git a/internal/tui3/placekeys.go b/internal/tui3/placekeys.go index 91de722f4e..a7840e1e55 100644 --- a/internal/tui3/placekeys.go +++ b/internal/tui3/placekeys.go @@ -76,6 +76,11 @@ func (a *app) placeKeyPress(msg tea.KeyPressMsg) tea.Cmd { if pl == nil { return nil } + // THE BARE DOOR DISARMS ON ANY KEY BUT THE SECOND SPACE, read before anything + // can take the key ([app.placeHomeGesture] re-arms it on a first space). + if msg.Key().Text != " " { + a.placeSpaceArmed = false + } // THE ROUTER'S OWN LAYER IS READ BEFORE THE PLACE'S, and it is the only thing // on this surface that is. The composer layer belongs to no place — the three // facts it settles are the same three wherever the sentence was typed — so a @@ -92,6 +97,16 @@ func (a *app) placeKeyPress(msg tea.KeyPressMsg) tea.Cmd { if cmd, took := a.placeKey(msg); took { return cmd } + // THE DOOR HOME IS READ HERE, at the bottom of the place router, because it + // must lose to every other meaning a space could have where a person is + // standing: the whole-keyboard layers above it, the router's six classes, and + // every key a place claims for its own rows. What is left — a plain space + // falling toward the place's box — is exactly what the door is made of + // ([app.placeHomeGesture]). It stands beside the conversation's own reading + // at the bottom of [app.key], one law about the box, two doors in. + if cmd, took := a.placeHomeGesture(msg); took { + return cmd + } return pl.key(a, msg) } @@ -395,6 +410,53 @@ func (a *app) placeBox() *editor { return pl.box(a) } +// placeHomeGesture is the door home read from WHATEVER PLACE IS STANDING: two +// spaces typed into that place's own box, the same two keystrokes that open it +// from inside a conversation (home.go's [app.homeGesture]). +// +// THE DOOR USED TO BE A CONVERSATION'S DOOR ONLY. The gesture lived at the +// bottom of [app.key], past the rung where a standing place takes the whole +// keyboard — so a place never saw the check, and a person standing on the +// search place, or the tasks place, or settings, had `space space` die under +// their hands while the tab bar sat one walk away. The person's words were +// `universal`, and this is what makes it so: the same guard +// ([app.homeDoorOpen] — anywhere but home itself, where the gesture is a no-op +// and the foot draws nothing), the same law about the box +// ([app.homeDoorArmed]), the same box the place was already typing into +// ([app.placeBox]). +// +// A PLACE WITH NO BOX HAS NO DOOR, and that is right rather than a gap: the +// gesture is a thing typed into a box, and where there is no box the key does +// nothing and always did. The box is RESET before home opens, because the two +// spaces were two spaces somebody typed and not a draft anybody meant to keep +// — the conversation's door empties its draft the same way ([app.key]). +func (a *app) placeHomeGesture(msg tea.KeyPressMsg) (tea.Cmd, bool) { + if !a.homeDoorOpen() { + return nil, false + } + box := a.placeBox() + // A PLACE WITH NO BOX STILL HAS THE DOOR. Spend and standing type into + // nothing (pages.go's [place.box]), so the two spaces are counted here + // rather than read back out of an editor: the first arms, the second + // opens, and any other key in between disarms ([app.placeKeyPress]). + if box == nil { + if msg.Key().Text != " " { + return nil, false + } + if a.placeSpaceArmed { + a.placeSpaceArmed = false + return a.openHome(), true + } + a.placeSpaceArmed = true + return nil, true + } + if !a.homeDoorArmed(box, msg) { + return nil, false + } + box.reset() + return a.openHome(), true +} + // placeSend is `alt+enter` over a composer with something in it: THE COMPOSER // LAYER OPENS, and a second press is what sends (composerlayer.go, SCREEN 2e). // diff --git a/internal/tui3/placemsgline_test.go b/internal/tui3/placemsgline_test.go index d0ca3ef46a..5fa396bd63 100644 --- a/internal/tui3/placemsgline_test.go +++ b/internal/tui3/placemsgline_test.go @@ -72,8 +72,8 @@ func TestANotelessFootKeepsTheDoorHintAsToday(t *testing.T) { a, _ := tasksFootApp(t) a.width, a.height = 100, 30 line := msgFootLine(a) - if line != "enter open its room · tab next place · esc home" { - t.Fatalf("a foot with nothing to say changed anyway:\n %q\nwant\n %q", line, "enter open its room · tab next place · esc home") + if line != "enter open its room · tab next place · esc close" { + t.Fatalf("a foot with nothing to say changed anyway:\n %q\nwant\n %q", line, "enter open its room · tab next place · esc close") } if strings.Contains(line, "waiting in this conversation") { t.Fatalf("a task that raised nothing drew a waiting note: %q", line) diff --git a/internal/tui3/render.go b/internal/tui3/render.go index 238ce22dd0..65df94751b 100644 --- a/internal/tui3/render.go +++ b/internal/tui3/render.go @@ -1770,7 +1770,7 @@ func (a *app) compactRow(e *entry, width int) string { // The bottom of this surface is TWO ROWS, and every element on them has exactly // one job (foot.go states the whole law): // -// ─ porting the parser · gpt-4.1-mini · ⠿ high · ◇ asks · via deepinfra ──── esc back · / commands ─ +// ─ porting the parser · gpt-4.1-mini · ⠿ high · ◇ asks · via deepinfra ──── space space home · / commands ─ // $0.14 · ⟲ saved $0.02 · 89% cached 12.4k/128k · 10% 2 jobs 92 tok/s · ⠹ working · 4s // // THE SEAM IS IDENTITY — which conversation, what is answering it, and the keys @@ -3503,12 +3503,12 @@ func (a *app) legendLinePainted(left, right, rightPainted string, width int, pai // a bug the breadcrumb bar exposed: this line is laid out by the pinned // header as well as by the legend (room.go, roomcrumbs.go), and the header is // drawn AFTER the chrome — so a header clearing the span erased a door the - // legend had just recorded, and `esc back` became a label nothing + // legend had just recorded, and `space space home` became a label nothing // answered for. What makes the span its own answer to "was it drawn" is // [app.legend] clearing it before its own ladder starts. - if offset := strings.Index(right, a.escapeDoorWord()); offset >= 0 { + if offset := strings.Index(right, homeDoorWord); offset >= 0 { from := at + ansi.StringWidth(right[:offset]) - a.homeDoor = hudSpan{from: from, to: from + ansi.StringWidth(a.escapeDoorWord())} + a.homeDoor = hudSpan{from: from, to: from + ansi.StringWidth(homeDoorWord)} } line := a.pal.dim("─") if left != "" { @@ -3716,7 +3716,7 @@ func (a *app) idleHint() string { } doors = append(doors, microcopy) if a.homeDoorShowing() { - doors = append(doors, a.escapeDoorWord()) + doors = append(doors, homeDoorWord) } return strings.Join(doors, hintSegment) } @@ -3752,6 +3752,7 @@ const hopDoorWord = hopOpenKey + " chats" // inside a fold enter choose · ← back · esc · crew max // the sessions are up enter open · esc // copy mode is on v select · a block · y yank · esc +// rewind is armed esc again to rewind (rewind.go's double esc) // rewind mode is up nothing — the mode bar prints its own keys // the welcome box is up ↑↓ recent · enter open // a path is completing tab take · enter run · esc @@ -3850,6 +3851,16 @@ func (a *app) hintWord() string { // box (rewind.go), and a slot repeating them would be the surface saying // the same thing twice on one screen. return "" + case a.rewindArmed() && a.rewindReady(): + // The first esc has landed and the second one means something else for + // half a second. This outranks "esc interrupt" below for exactly that + // reason: while the window is open, that is no longer what the key does. + // + // It asks [app.rewindReady] as well as the clock, because the two can come + // apart: a question can be raised in the half second the window is open, + // and from that moment esc belongs to the question. The slot promises what + // the NEXT esc does, so it has to ask the same thing that key will. + return rewindArmWord case a.rewindSaying(): return a.rewSay case a.welcome.open: @@ -3898,7 +3909,7 @@ func (a *app) hintWord() string { // everything the draft would have got. And it ranks ABOVE the two lines // below for the reason this whole slot is ordered the way it is — while a // room is open, esc leaves the page and does not touch the conversation's - // turn, so "ctrl+c interrupt" would be naming a key that is spoken for. + // turn, so "esc interrupt" would be naming a key that is spoken for. return a.roomHint() case a.state == stateWorking: // ONE RUNNING STATE, ONE COMPOSED LINE (steer.go's [app.runHint]). Its diff --git a/internal/tui3/rewind.go b/internal/tui3/rewind.go index 00f98efffa..892752c007 100644 --- a/internal/tui3/rewind.go +++ b/internal/tui3/rewind.go @@ -38,8 +38,14 @@ import ( // could enter without first saving their own work somewhere else, and esc puts // the sentence back exactly as it was. // -// Escape is reserved for back navigation. /rewind opens the timeline; this -// inline renderer remains available to internal callers. +// THE DOOR IS DOUBLE-ESC, and it is double for one reason: esc already means +// INTERRUPT while a turn runs, and that meaning is not for sale. So the first esc +// keeps whatever it always meant — it stops the model mid-turn, and at rest it +// does nothing at all — and it ARMS this mode for [rewindArmWindow]; a second esc +// inside that window opens it. Interrupt first, then rewind, in the order the +// engine's own refusal asks for ([session.ErrTurnInFlight]): the pair is one +// gesture, and it is the gesture a person's hand already makes when they want to +// take something back. // // KEYS: ↑/↓ walk the TURNS, which is the unit a person thinks in ("not that // message"); ←/→ slide through the steps inside the turn the cut is in, for the @@ -56,6 +62,23 @@ import ( // one of them to be derived from the other — which is exactly what the resume // path already does. +// rewindArmWindow is how long the first esc keeps rewind armed. +// +// HALF A SECOND, and the number is chosen against the HAND rather than against a +// reaction time. A deliberate double-tap — the double-click every pointer on this +// machine is calibrated for, and the "esc esc" that leaves an editor's mode — +// lands its second key inside 300ms; a person who pressed esc to interrupt a turn +// and then decided, having read something, to also take it back is a person +// making a second decision, and their second key arrives a second or more later. +// So the window has to be long enough that the first kind never misses and short +// enough that the second kind never fires by accident. +// +// It errs SHORT on purpose. A window that lapsed too early costs one extra +// keystroke; a window that lapsed too late puts a person who tapped esc twice to +// dismiss two things into a mode they did not ask for, over a conversation they +// are about to cut. The two mistakes are not the same size. +const rewindArmWindow = 500 * time.Millisecond + // rewindSayWindow is how long a sentence this mode could not act on stays in the // hint slot — "nothing to rewind", and nothing else. Two and a half seconds is // long enough to be read once and short enough that it is never furniture. @@ -63,6 +86,8 @@ const rewindSayWindow = 2500 * time.Millisecond // The mode's words, written down once. const ( + // rewindArmWord is what the hint slot says while the first esc is still warm. + rewindArmWord = "esc again to rewind" // rewindEmptyWord is what it says when there is nothing to cut. rewindEmptyWord = "nothing to rewind" // rewindCutWord is the cut line's label, and rewindKeysWord the mode bar's @@ -149,6 +174,7 @@ func (a *app) enterRewind() tea.Cmd { if len(points) == 0 { return a.sayRewind(rewindEmptyWord) } + a.disarmRewind() a.closeLists() a.dropHover() a.rew = rewindMode{ @@ -196,10 +222,64 @@ func (a *app) leaveRewind(restore bool) { a.touch() } +// ── THE DOUBLE ESC ────────────────────────────────────────────────────────── + +// escRewind is esc's rewind half. It reports whether it TOOK the key: the second +// esc inside the window opens the mode and is taken, and the first one arms and +// is NOT — it goes on to mean whatever it always meant, which mid-turn is the +// interrupt (input.go's esc case). The command it returns is carried down both +// paths, because arming needs the frame clock to run the window down. +func (a *app) escRewind() (tea.Cmd, bool) { + if !a.rewindReady() { + return nil, false + } + if a.rewindArmed() { + return a.enterRewind(), true + } + a.escArm = a.now() + a.touch() + return a.wake(), false +} + +// rewindReady reports whether esc may arm the mode at all: nothing else on this +// surface is holding the keyboard, and the agent under it can rewind. +// +// It is the LIST OF STATES esc already means something in, read from the routers +// that take the key before input.go's own switch does (input.go, app.go's Update) +// — a modal state whose dismiss key silently armed a second mode would be a +// surface where esc means two things at once. +func (a *app) rewindReady() bool { + if a.rew.on { + return false + } + if _, ok := a.rewinder(); !ok { + return false + } + switch { + case a.rewSheet.open, + a.at(pageSettings), a.at(pageTasks), a.deck.open, a.expand.open, a.pick.open, a.roster.open, + a.connPanel.open, a.menu.open, a.comp.open, a.welcome.open, + a.copy.on, a.recalling(), a.roomOpen(), a.railHold, a.railFull(), + a.asking(), a.awaitingTask(), a.guard != nil, a.asksConnect(), + a.asksHarness(): + return false + } + return true +} + +// rewindArmed reports whether the first esc is still warm. +func (a *app) rewindArmed() bool { + return !a.escArm.IsZero() && a.now().Sub(a.escArm) < rewindArmWindow +} + +// disarmRewind forgets it. +func (a *app) disarmRewind() { a.escArm = time.Time{} } + // sayRewind puts one sentence in the hint slot for a moment — "nothing to // rewind", which is the whole of what this is for. func (a *app) sayRewind(text string) tea.Cmd { a.rewSay, a.rewSayAt = text, a.now() + a.disarmRewind() a.touch() return a.wake() } @@ -211,13 +291,17 @@ func (a *app) rewindSaying() bool { // rewindTicking says the frame clock has a reason to keep turning even with // nothing else happening: a window or a sentence with an end to reach. -func (a *app) rewindTicking() bool { return a.rewindSaying() } +func (a *app) rewindTicking() bool { return a.rewindArmed() || a.rewindSaying() } // rewindSweep runs both of those clocks down. It is called from [app.paint] and // from nowhere else — NO GOROUTINE OF ITS OWN, for the reason the spinner and the // countdowns have none (app.go): this surface has exactly one clock, and a second // one is a second wakeup per second and two states that disagree about the time. func (a *app) rewindSweep() { + if !a.escArm.IsZero() && !a.rewindArmed() { + a.disarmRewind() + a.touch() + } if a.rewSay != "" && !a.rewindSaying() { a.rewSay, a.rewSayAt = "", time.Time{} a.touch() diff --git a/internal/tui3/rewind_test.go b/internal/tui3/rewind_test.go index 159dcc9afe..8af385ab13 100644 --- a/internal/tui3/rewind_test.go +++ b/internal/tui3/rewind_test.go @@ -102,6 +102,90 @@ func rewindRowY(a *app, want string) (int, bool) { // ── 1. the door ───────────────────────────────────────────────────────────── +// The first esc arms and says so; the second one inside the window opens the +// mode over the conversation. +func TestEscTwiceInsideTheWindowEntersRewind(t *testing.T) { + a, _ := newRewindApp(t, rewindPast()) + + drive(t, a, key("esc")) + if !a.rewindArmed() { + t.Fatal("the first esc did not arm the rewind") + } + if a.rew.on { + t.Fatal("one esc entered the mode") + } + if got := a.hintWord(); got != rewindArmWord { + t.Fatalf("hint slot = %q, want %q", got, rewindArmWord) + } + if got := plain(frame(a)); !strings.Contains(got, rewindArmWord) { + t.Fatalf("the armed frame does not say so:\n%s", got) + } + + drive(t, a, key("esc")) + if !a.rew.on { + t.Fatal("the second esc did not enter the mode") + } +} + +// A window that lapses takes the meaning with it: the next esc is an ordinary +// esc, and the mode stays shut. +func TestTheArmLapsesAndAStrayEscChangesNothing(t *testing.T) { + a, _ := newRewindApp(t, rewindPast()) + now := time.Now() + a.clock = func() time.Time { return now } + + drive(t, a, key("esc")) + if !a.rewindArmed() { + t.Fatal("the first esc did not arm the rewind") + } + + now = now.Add(rewindArmWindow + time.Millisecond) + // The frame clock is what runs the window down — no goroutine of its own. + drive(t, a, frameMsg{}) + if a.rewindArmed() || !a.escArm.IsZero() { + t.Fatal("the arm survived its window") + } + if got := a.hintWord(); got == rewindArmWord { + t.Fatal("the hint slot still offers a rewind after the window lapsed") + } + + drive(t, a, key("esc")) + if a.rew.on { + t.Fatal("a stray esc after the window entered the mode") + } +} + +// Mid-turn the first esc keeps its own meaning — it stops the model — and the +// second one inside the window still opens the mode. Interrupt first, then +// rewind, which is the order the engine's refusal asks for. +func TestEscMidTurnInterruptsFirstAndThenRewinds(t *testing.T) { + a, agent := newRewindApp(t, rewindPast()) + drive(t, a, submittedMsg{ch: make(chan session.Event)}) + a.state = stateWorking + + drive(t, a, key("esc")) + if agent.stops != 1 { + t.Fatalf("the first esc mid-turn did not interrupt (%d)", agent.stops) + } + if !a.rewindArmed() { + t.Fatal("the first esc mid-turn did not arm the rewind") + } + drive(t, a, key("esc")) + if !a.rew.on { + t.Fatal("the second esc mid-turn did not enter the mode") + } +} + +// A backend that cannot rewind simply has no rewind: esc means what it always +// meant, twice. +func TestASurfaceWithoutARewinderNeverArms(t *testing.T) { + a := newTestApp(&fakeAgent{model: "m"}) + drive(t, a, key("esc"), key("esc")) + if a.rewindArmed() || a.rew.on { + t.Fatal("a surface with no rewind door opened one") + } +} + // ── 2. the mode ───────────────────────────────────────────────────────────── // The door a /rewind command calls opens the same mode the keys do: a cut line diff --git a/internal/tui3/rewindsheet.go b/internal/tui3/rewindsheet.go index 10ea5d864a..b419ac0fb8 100644 --- a/internal/tui3/rewindsheet.go +++ b/internal/tui3/rewindsheet.go @@ -25,9 +25,14 @@ import ( // ⟲ drops 1 turn — everything below the pick is let go // esc close · ↑↓ move · enter picks the point // -// /rewind opens this timeline explicitly. Escape only backs out; it never -// enters a rewind mode. The full history stays reachable here even when the -// transcript view has loaded only its most recent blocks. +// THERE ARE TWO REWINDS AND THEY ANSWER TWO DIFFERENT QUESTIONS. esc esc opens +// the INLINE mode (rewind.go), which is the quick take-back: the transcript on +// screen is the picker, the answer is almost always "the thing I just said", and +// the whole gesture is over in two keystrokes. This page is the DELIBERATE one — +// "take me back to before we started down this road" — and the question it +// answers cannot be answered by the inline mode at all, because the inline mode +// walks the DRAWN blocks and a resumed conversation draws only its last +// [replayTail] entries. Everything older than that was unreachable. // // SO THIS PAGE IS BUILT FROM THE SESSION AND NOT FROM THE SCREEN. Its rows come // out of [session.Agent.Transcript] and its points out of @@ -202,6 +207,7 @@ func (a *app) openRewindSheet() tea.Cmd { if len(points) == 0 { return a.sayRewind(rewindEmptyWord) } + a.disarmRewind() a.closeLists() a.dropHover() // THE OTHER FULLSCREEN PAGES STAND DOWN, which is the law settings.go states: diff --git a/internal/tui3/room.go b/internal/tui3/room.go index 141357dc59..24429cf077 100644 --- a/internal/tui3/room.go +++ b/internal/tui3/room.go @@ -1911,8 +1911,13 @@ func (a *app) roomKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { } switch msg.String() { case "esc": - // Escape backs out without stopping work, as it does in the main - // conversation. Stopping a task remains an explicit x and confirmation. + // ESC IN HERE IS THE DOOR AND IT IS NEVER A STOP — stop.go's standing law, + // restated at the keystroke it is about. Out in the conversation esc + // interrupts the running turn; the analogous act in a room is ending the + // node, which is not reversible and is therefore always asked first (`x`, + // and the card). So the two surfaces do NOT converge on this key, and the + // legend says which of the two meanings is live: while a room is open the + // hint slot never reads "esc interrupt" (render.go's [app.hintWord]). // // A recall walk is left first, for the reason input.go leaves it first: a // state that could not be dismissed by the dismiss key is a trap, and the @@ -2010,7 +2015,7 @@ func (a *app) roomKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { // roomHint is the hint slot while a room is open (render.go's [app.hintWord]), // and it exists because that slot used to LIE in here: with a turn running out -// in the conversation it drew "ctrl+c interrupt" over a page where esc leaves the +// in the conversation it drew "esc interrupt" over a page where esc leaves the // room and interrupts nothing. A hint naming a key that does something else is // the one failure the slot exists to prevent. // @@ -2041,7 +2046,7 @@ func (a *app) roomHint() string { return "/model · /stop · esc main" } // THE ROOM'S ANSWER TO "HOW DO I STOP THIS". It is the honest counterpart - // to the conversation's "ctrl+c interrupt": the work in here ends through a + // to the conversation's "esc interrupt": the work in here ends through a // card and never through the dismiss key (stop.go), so this is the key a // person reaching for esc actually wants. It is drawn only while there is // something to stop, which is the emptiness law applied to a hint. diff --git a/internal/tui3/roomrecall_test.go b/internal/tui3/roomrecall_test.go index 7840064a3f..ea4b986174 100644 --- a/internal/tui3/roomrecall_test.go +++ b/internal/tui3/roomrecall_test.go @@ -157,13 +157,13 @@ func TestUpScrollsTheRoomWhenThereIsNoHistory(t *testing.T) { } } -// THE HINT SLOT MUST NOT SAY "ctrl+c interrupt" IN A ROOM, because esc in here +// THE HINT SLOT MUST NOT SAY "esc interrupt" IN A ROOM, because esc in here // leaves the page and interrupts nothing (room.go's [app.roomHint]). func TestTheHintSlotInARoomNeverPromisesAnInterrupt(t *testing.T) { a, _ := roomRecallApp(t) a.state = stateWorking - if hint := a.hintWord(); hint == "ctrl+c interrupt" { + if hint := a.hintWord(); hint == "esc interrupt" { t.Fatal("the hint slot promised an interrupt from inside a room") } // What it says instead is the key that actually ends the work in here. @@ -171,7 +171,7 @@ func TestTheHintSlotInARoomNeverPromisesAnInterrupt(t *testing.T) { t.Fatalf("the room's hint reads %q, want %q", hint, roomStopHint) } drive(t, a, key("esc")) - if hint := a.hintWord(); hint != "ctrl+c interrupt" { + if hint := a.hintWord(); hint != "esc interrupt" { t.Fatalf("back in the conversation the hint reads %q", hint) } } diff --git a/internal/tui3/settings.go b/internal/tui3/settings.go index d3387210ec..91208c378d 100644 --- a/internal/tui3/settings.go +++ b/internal/tui3/settings.go @@ -2110,7 +2110,8 @@ func (a *app) sheetKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { if a.connEsc() { return nil, true } - return a.openHome(), true + a.closeSettings() + return nil, true // ← AND → MOVE THIS PANEL'S OWN SECTIONS, and `tab` no longer does. `tab` is // the way to the NEXT PLACE now (pages.go), and a key that meant "next diff --git a/internal/tui3/slashchip.go b/internal/tui3/slashchip.go index 6e59db0835..d20857d038 100644 --- a/internal/tui3/slashchip.go +++ b/internal/tui3/slashchip.go @@ -50,7 +50,6 @@ const ( sendDoorNone sendDoor = iota sendDoorStanding sendDoorTask - sendDoorAsk ) const ( @@ -214,9 +213,6 @@ func (a *app) slashTagHint() string { if commandDoor(word) == sendDoorStanding { return slashTagHintStanding } - if commandDoor(word) == sendDoorAsk { - return "" - } return slashTagHintTask } diff --git a/internal/tui3/steer.go b/internal/tui3/steer.go index 3e02018d19..c056faaa97 100644 --- a/internal/tui3/steer.go +++ b/internal/tui3/steer.go @@ -462,7 +462,7 @@ func (a *app) runSendOffered() bool { func (a *app) runHint() string { if a.questionWriting() { // WHILE THE BOX IS A QUESTION'S, THE QUESTION'S ROW IS THE HINT. - // `enter steers it in · ctrl+c interrupt` over a box whose enter answers + // `enter steers it in · esc interrupt` over a box whose enter answers // a card and whose esc gives the box back would be two keys named // wrong on one screen ([app.questionWritingRow] says them right). return "" @@ -474,7 +474,7 @@ func (a *app) runHint() string { if a.promotableRow() >= 0 { parts = append(parts, "ctrl+g backgrounds") } - stop := "ctrl+c interrupt" + stop := "esc interrupt" if len(a.parks) > 0 && a.parking() { stop = parkedHint[1] } diff --git a/internal/tui3/steer_test.go b/internal/tui3/steer_test.go index 3b64e4ef94..61826a163c 100644 --- a/internal/tui3/steer_test.go +++ b/internal/tui3/steer_test.go @@ -583,7 +583,7 @@ func TestTheHintUnderTheBoxTeachesTheSteerWhereTheChordCanBeDelivered(t *testing a, _ := steerableTurn(t, "reading the tree. ") typeInto(t, a, "no, the other file") - want := "enter " + steerSendWord + " · " + bargeKey + " " + bargeSendWord + " · ctrl+c interrupt" + want := "enter " + steerSendWord + " · " + bargeKey + " " + bargeSendWord + " · esc interrupt" if got := a.hintWord(); got != want { t.Fatalf("the hint slot reads %q, want %q", got, want) } @@ -592,7 +592,7 @@ func TestTheHintUnderTheBoxTeachesTheSteerWhereTheChordCanBeDelivered(t *testing // because steering does not need a modified-key protocol. The unavailable // secondary chords are the only clauses removed. a.keysDisambiguated = false - if got := a.hintWord(); got != steerShortHint+" · ctrl+c interrupt" { + if got := a.hintWord(); got != steerShortHint+" · esc interrupt" { t.Fatalf("a basic terminal lost the plain-enter steer: %q", got) } } @@ -604,13 +604,13 @@ func TestAPictureOnTheTrayKeepsThePlainEnterHint(t *testing.T) { a, _ := steerableTurn(t, "reading the tree. ") a.chips = []chip{{path: "/tmp/shot.png"}} - want := enterWaitHint + " · " + bargeKey + " " + bargeSendWord + " · ctrl+c interrupt" + want := enterWaitHint + " · " + bargeKey + " " + bargeSendWord + " · esc interrupt" if got := a.hintWord(); got != want { t.Fatalf("the tray-only hint reads %q, want %q", got, want) } a.keysDisambiguated = false - if got := a.hintWord(); got != enterWaitHint+" · ctrl+c interrupt" { + if got := a.hintWord(); got != enterWaitHint+" · esc interrupt" { t.Fatalf("a basic terminal lost the tray's plain-enter hint: %q", got) } } @@ -651,7 +651,7 @@ func TestANarrowFrameKeepsTheShorterHintRatherThanLosingTheSlot(t *testing.T) { if !strings.Contains(body, steerShortHint) { t.Fatalf("the narrow frame lost the whole hint slot:\n%s", body) } - if strings.Contains(body, whole) || strings.Contains(body, "ctrl+c interrupt") { + if strings.Contains(body, whole) || strings.Contains(body, "esc interrupt") { t.Fatalf("the narrow ladder did not drop from the right:\n%s", body) } } @@ -659,7 +659,7 @@ func TestANarrowFrameKeepsTheShorterHintRatherThanLosingTheSlot(t *testing.T) { // AND THE WAITING MESSAGE'S OWN LINE CARRIES THE ARROW, unconditionally as far // as the terminal is concerned: an arrow key reaches every terminal there is, so // there is nothing to gate the clause on but whether the act itself is possible. -func TestTheStripNamesTheArrowAndCtrlCDropsTheWholeWaitingBlock(t *testing.T) { +func TestTheStripNamesTheArrowAndEscDropsTheWholeWaitingBlock(t *testing.T) { a, agent := steerableTurn(t, "reading the tree. ") a.width = 90 parkLine(t, a, "do much more of a deep research please") @@ -677,7 +677,7 @@ func TestTheStripNamesTheArrowAndCtrlCDropsTheWholeWaitingBlock(t *testing.T) { // ESC drops the queue at the keypress, so a winding-down turn has neither a // stale message nor an arrow that claims it can still cross a boundary. - drive(t, a, key("ctrl+c"), frameMsg{}) + drive(t, a, key("esc"), frameMsg{}) if !a.windingDown() { t.Fatal("the surface is not winding down after esc") } diff --git a/internal/tui3/stopbound_test.go b/internal/tui3/stopbound_test.go index 69f680311d..7f89c80200 100644 --- a/internal/tui3/stopbound_test.go +++ b/internal/tui3/stopbound_test.go @@ -48,7 +48,7 @@ func boundedStopApp(t *testing.T) (*app, *abandoningAgent, func(time.Duration)) drive(t, a, submittedMsg{ch: make(chan session.Event)}) a.state = stateWorking a.turnBegan = a.now() - drive(t, a, key("ctrl+c")) + drive(t, a, key("esc")) return a, agent, func(d time.Duration) { now = now.Add(d) } } @@ -88,7 +88,7 @@ func TestTheStoppingLineSaysWhenItWillDetach(t *testing.T) { // no way to perform. func TestAStopWithNoDoorBehindItAnnouncesNoBound(t *testing.T) { a, _ := stoppingApp(t) - drive(t, a, key("ctrl+c")) + drive(t, a, key("esc")) if a.stopBounded() { t.Fatal("a surface with no abandon door claims a bound it cannot keep") diff --git a/internal/tui3/stopping_test.go b/internal/tui3/stopping_test.go index 496f79e492..9ab386a2d6 100644 --- a/internal/tui3/stopping_test.go +++ b/internal/tui3/stopping_test.go @@ -16,7 +16,7 @@ import ( // loop lets go, which for a `bash` holding a leaked pipe or a `jobs` kill is // three or four seconds, and for the whole of that window the surface went on // drawing what arrived. What is pinned here is the law that closed it: the frame -// after ctrl+c shows no motion, claims "working" nowhere, and draws nothing new +// after esc shows no motion, claims "working" nowhere, and draws nothing new // until the turn is over ([app.windingDown], [keptAfterStop], [stoppingWord]). // stoppingApp is a turn caught mid-flight, with the two things on screen that a @@ -53,20 +53,20 @@ func TestTheFrameStillsOnTheKeyAndClaimsWorkingNowhere(t *testing.T) { t.Fatalf("the turn was not working before the key:\n%s", before) } - drive(t, a, key("ctrl+c")) + drive(t, a, key("esc")) if agent.stops != 1 { t.Fatalf("esc did not stop the turn (%d)", agent.stops) } after := plain(frame(a)) if strings.Contains(after, stateWorking.String()) { - t.Fatalf("the frame after ctrl+c still says working:\n%s", after) + t.Fatalf("the frame after esc still says working:\n%s", after) } // THE SPINNER IS THE MOTION, and there are two of it on this frame — the // status line's and the tool row's. Neither cell may survive the key: a still // frame with one thing turning in it is the surface insisting on something // the person has just ended. if strings.ContainsAny(after, spinnerFrames()) { - t.Fatalf("a spinner is still turning after ctrl+c:\n%s", after) + t.Fatalf("a spinner is still turning after esc:\n%s", after) } // AND THE ROW CARRIES ITS OWN END, rather than merely being drawn quietly // because the session happens not to be working. @@ -80,10 +80,10 @@ func TestTheFrameStillsOnTheKeyAndClaimsWorkingNowhere(t *testing.T) { // takes, and gives the word up for the fact the moment the stream closes. func TestTheStatusLineSaysStoppingUntilTheStreamCloses(t *testing.T) { a, _ := stoppingApp(t) - drive(t, a, key("ctrl+c")) + drive(t, a, key("esc")) if !a.windingDown() { - t.Fatal("the surface is not winding down after ctrl+c") + t.Fatal("the surface is not winding down after esc") } word, painted := a.stateWord() if word != stoppingWord { @@ -116,7 +116,7 @@ func TestTheStatusLineSaysStoppingUntilTheStreamCloses(t *testing.T) { // drew a fresh tool row in the same place, for work that was never going to run. func TestNothingArrivingAfterTheStopIsDrawn(t *testing.T) { a, _ := stoppingApp(t) - drive(t, a, key("ctrl+c")) + drive(t, a, key("esc")) was := len(a.entries) said := plain(frame(a)) @@ -144,7 +144,7 @@ func TestNothingArrivingAfterTheStopIsDrawn(t *testing.T) { // of ([keptAfterStop]). func TestALateToolCloseStillLandsOnTheRowItBelongsTo(t *testing.T) { a, _ := stoppingApp(t) - drive(t, a, key("ctrl+c")) + drive(t, a, key("esc")) was := len(a.entries) drive(t, a, streamEventMsg{gen: a.gen, ev: session.Event{Kind: session.EventToolEnd, @@ -165,7 +165,7 @@ func TestALateToolCloseStillLandsOnTheRowItBelongsTo(t *testing.T) { // and the usage rides on the two events that end one. func TestTheStoppedTurnStillTakesItsUsage(t *testing.T) { a, _ := stoppingApp(t) - drive(t, a, key("ctrl+c")) + drive(t, a, key("esc")) drive(t, a, streamEventMsg{gen: a.gen, ev: session.Event{Kind: session.EventTurnDone, Usage: session.Usage{Input: 900, Output: 100, CostUSD: 0.25}}}) @@ -177,6 +177,97 @@ func TestTheStoppedTurnStillTakesItsUsage(t *testing.T) { } } +// ── 3. the esc mash ───────────────────────────────────────────────────────── + +// THE EVERYDAY GESTURE: a person mashes esc at a turn they want stopped. The +// first one stops it; the second, inside [rewindArmWindow], opens the rewind +// over a conversation they were not thinking about cutting; the third leaves. +// +// NOTHING DESTRUCTIVE CAN COME OF IT. The mode does nothing without enter, esc +// puts the sentence they were typing back exactly as it was, and the engine is +// never asked to cut anything. +func TestMashingEscStopsTheTurnAndLeavesTheConversationWhole(t *testing.T) { + a, agent := newRewindApp(t, rewindPast()) + drive(t, a, submittedMsg{ch: make(chan session.Event)}) + a.state = stateWorking + for _, r := range "half a thought" { + drive(t, a, key(string(r))) + } + + drive(t, a, key("esc")) + if agent.stops != 1 { + t.Fatalf("the first esc did not stop the turn (%d)", agent.stops) + } + // Counted AFTER the stop, because the stop writes its own `interrupted` line + // and that line is the one thing a mash is supposed to leave behind. + before := len(a.entries) + drive(t, a, key("esc")) + if !a.rew.on { + t.Fatal("the second esc did not open the rewind") + } + // THE MODE OPENS CALM. Nothing has been cut, and the draft is being held + // rather than spent. + if len(agent.cuts) != 0 { + t.Fatalf("opening the mode cut the conversation at %v", agent.cuts) + } + if got := string(a.rew.draft); got != "half a thought" { + t.Fatalf("the mode is holding %q, want the sentence in the box", got) + } + + drive(t, a, key("esc")) + if a.rew.on { + t.Fatal("the third esc did not leave the mode") + } + if len(agent.cuts) != 0 { + t.Fatalf("mashing esc cut the conversation at %v", agent.cuts) + } + if got := string(a.input.value); got != "half a thought" { + t.Fatalf("the sentence came back as %q", got) + } + if len(a.entries) != before { + t.Fatalf("mashing esc changed the conversation: %d blocks, was %d", len(a.entries), before) + } + // AND THE STOP IS STILL THE STOP. The rewind rode on top of it and took + // nothing from it. + if agent.stops != 1 { + t.Fatalf("the mash stopped the turn %d times", agent.stops) + } +} + +// A MASH THAT KEEPS GOING IS STILL SAFE. Past the third key the pair starts over +// — arm, open, leave — and the sentence survives every round of it. +func TestAnEndlessEscMashNeverCutsAnything(t *testing.T) { + a, agent := newRewindApp(t, rewindPast()) + drive(t, a, submittedMsg{ch: make(chan session.Event)}) + a.state = stateWorking + for _, r := range "keep me" { + drive(t, a, key(string(r))) + } + + for range 9 { + drive(t, a, key("esc")) + } + if len(agent.cuts) != 0 { + t.Fatalf("nine escs cut the conversation at %v", agent.cuts) + } + // The mode is either up holding the sentence or down with it back in the + // box; both are the same promise, and one of the two is always true. + held := string(a.input.value) + if a.rew.on { + held = string(a.rew.draft) + } + if held != "keep me" { + t.Fatalf("the sentence is %q after nine escs", held) + } +} + +// ── 4. the same law in a task's room ──────────────────────────────────────── + +// THE ROOM SAYS THE SAME WORD. Stopping a node is not esc — in a room esc is the +// door and never a stop (stop.go) — but the window after the answer is the +// conversation's window exactly: the child's context is cut and the child is +// winding up, and for the whole of it this header used to read "working" about +// work the person had just ended. func TestAStoppedNodesRoomSaysStoppingRatherThanWorking(t *testing.T) { a, _ := stopApp(t) drive(t, a, streamEventMsg{gen: a.gen, ev: update(7, "Fix the nil-map crash", diff --git a/internal/tui3/subharness.go b/internal/tui3/subharness.go index 670289009f..206b79bbd2 100644 --- a/internal/tui3/subharness.go +++ b/internal/tui3/subharness.go @@ -151,9 +151,11 @@ const ( subRunVerbs = "↑↓ · enter runs it · esc back" // subOfferVerbs is the hint while the cursor is on the answer row of a card // CHAT raised. It names the keys that row actually draws and not one more: - // The chips are walked with arrows and taken with Enter. Zero declines; - // Escape hides the card without answering. - subOfferVerbs = "←→ · enter takes it · 0 no · esc back" + // the chips are walked with ←/→ and taken with enter, and both `0` and `esc` + // are the no — `esc` because it is the dismiss key everywhere in a + // conversation, `0` because it is the decline every card on this surface + // answers to (standing.go's [session.StandingNoKey]). + subOfferVerbs = "←→ · enter takes it · 0 or esc, no" // subEditHint is the placeholder in the box while one field is being typed // into. It takes the filter's place — one box under the overlay, answering // one question at a time (the connections panel's key box does the same). @@ -856,11 +858,6 @@ func (p *subPage) drawCard(a *app, width, n int, hover int) []string { // onto it, so the refusal names that machine before this code asks the local // seam. A capability that cannot work is absent rather than broken. func (a *app) openSubharness(name string) { - if a.subPage.card.asked() { - a.subPage.open = true - a.touch() - return - } if a.hosted() { a.note(a.remoteProfileWord("subharnesses")) return @@ -1001,11 +998,11 @@ func (a *app) withdrawSubharnessProposal(id uint64, name string) { // turn is technically working — the propose_subharness call is parked inside its // batch — and what is true about it that a person can act on is that it is // waiting for them (render.go's [app.stateWord]). -func (a *app) awaitingSubharness() bool { return a.subPage.card.asked() } +func (a *app) awaitingSubharness() bool { return a.subPage.open && a.subPage.card.asked() } // answerSubharnessOffer is the one place [subharnessOfferAgent.ResolveSubharness] -// is called from: the two chips, -// `0`, and the run row's own enter. Escape only defers the card. +// is called from, and every road off this card ends here: the two chips, `esc`, +// `0`, and the run row's own enter. // // THE CARD CLOSES EITHER WAY, because both answers are answers — a decline is // not an abandonment, and the engine is told so rather than left to time out on @@ -1121,11 +1118,12 @@ func (a *app) subCardKey(msg tea.KeyPressMsg) tea.Cmd { var cmd tea.Cmd switch msg.String() { case "esc": - // Back defers the offer without answering it. /subharness restores - // this same card until the engine answers or withdraws it. + // ESC ON A CARD CHAT RAISED IS THE NO, AND NOT AN ABANDONMENT. There is + // a turn on the other end of this question; walking away from it would + // leave that turn parked for a quarter of an hour on an answer the person + // has already given by pressing the dismiss key. if c.asked() { - a.subPage.open = false - a.note("subharness waiting · /subharness to return") + cmd = a.answerSubharnessOffer(false) break } // BACK OUT BY ONE. A card opened off the list goes back to the list; one diff --git a/internal/tui3/subharness_test.go b/internal/tui3/subharness_test.go index 6b57b5cbe2..970d1f8494 100644 --- a/internal/tui3/subharness_test.go +++ b/internal/tui3/subharness_test.go @@ -669,8 +669,8 @@ func TestTheStatusLineSaysYourCallWhileTheOfferStands(t *testing.T) { t.Fatalf("the status line read %q while a card was up", word) } drive(t, a, key("esc")) - if !a.awaitingSubharness() { - t.Fatal("a deferred offer stopped being waited on") + if a.awaitingSubharness() { + t.Fatal("an answered offer is still being waited on") } } @@ -701,15 +701,16 @@ func TestEnterOnTheAnswersRunsWhatChatOffered(t *testing.T) { } } -// Escape puts the offer aside without answering the turn waiting for it. -func TestEscOnAnOfferDefersWithoutAnswering(t *testing.T) { +// ESC IS A NO AND NOT A WAY OUT. There is a turn waiting on this question, so +// the key that dismisses every other overlay answers this one. +func TestEscOnAnOfferAnswersNoRatherThanWalkingAway(t *testing.T) { a, agent := answeredApp(t) drive(t, a, key("esc")) - if len(agent.answers) != 0 { + if len(agent.answers) != 1 || agent.answers[0].run { t.Fatalf("esc on the card answered %+v", agent.answers) } if a.subPage.open { - t.Fatal("the card stayed up after it was deferred") + t.Fatal("the card stayed up after it was declined") } } diff --git a/internal/tui3/surface_test.go b/internal/tui3/surface_test.go index 0c2f71dc25..57ebbf1528 100644 --- a/internal/tui3/surface_test.go +++ b/internal/tui3/surface_test.go @@ -285,7 +285,7 @@ func TestTheOpeningHintNamesBothDoors(t *testing.T) { // THE EXIT IS TAUGHT AFTER THE ENTRANCE (welcome.go's [app.dismissWelcome]): // the greeting's frame carries no line about leaving, and the line lands the // moment the conversation begins. - if strings.Contains(plain(frame(a)), "esc back · ctrl+c interrupts or quits") { + if strings.Contains(plain(frame(a)), "esc interrupts · ctrl+c quits") { t.Fatalf("the greeting teaches the way out before the way in:\n%s", plain(frame(a))) } drive(t, a, key("h")) @@ -295,15 +295,14 @@ func TestTheOpeningHintNamesBothDoors(t *testing.T) { if !strings.Contains(plain(frame(a)), welcomeStarterKeysWord) { t.Fatalf("typing moved the first conversation's composer out from under the person:\n%s", plain(frame(a))) } - if strings.Contains(plain(frame(a)), "esc back · ctrl+c interrupts or quits") { + if strings.Contains(plain(frame(a)), "esc interrupts · ctrl+c quits") { t.Fatalf("a keystroke dismissed the first conversation's greeting:\n%s", plain(frame(a))) } // THE HINT LANDS WHEN THE CONVERSATION BEGINS — the send, not the typing // (welcome.go's [app.spendWelcome]). IT HAS TO BE TRUE ON THAT FRAME, where - // a turn has just started: esc goes back, and ctrl+c interrupts while - // working or quits at rest. + // a turn has just started: esc interrupts and ctrl+c quits at rest. drive(t, a, key("enter")) - if !strings.Contains(plain(frame(a)), "esc back · ctrl+c interrupts or quits") { + if !strings.Contains(plain(frame(a)), "esc interrupts · ctrl+c quits") { t.Fatalf("the hint has to name both doors truthfully, and it lands at the send:\n%s", plain(frame(a))) } // CASE TWO, A PROFILE THAT HAS MET THE SETUP: the marker is in the test's @@ -316,18 +315,18 @@ func TestTheOpeningHintNamesBothDoors(t *testing.T) { b := newApp(t.Context(), Options{Agent: &fakeAgent{model: "m"}, Workspace: "/tmp/lab", ProfileDir: metSetup}) b.width, b.height = 90, 30 b.touch() - if strings.Contains(plain(frame(b)), "esc back · ctrl+c interrupts or quits") { + if strings.Contains(plain(frame(b)), "esc interrupts · ctrl+c quits") { t.Fatalf("the greeting taught the way out before the way in on a profile that has met the setup:\n%s", plain(frame(b))) } drive(t, b, key("h")) - if !strings.Contains(plain(frame(b)), "esc back · ctrl+c interrupts or quits") { + if !strings.Contains(plain(frame(b)), "esc interrupts · ctrl+c quits") { t.Fatalf("a keystroke on a profile that has met the setup did not land the hint:\n%s", plain(frame(b))) } // And a session that opens on a transcript gets it on its first frame. resumed := newApp(t.Context(), Options{Agent: &fakeAgent{model: "m", past: []session.DisplayEntry{{Role: "user", Text: "hi"}}}, Workspace: "/tmp/lab", Resumed: true, ProfileDir: t.TempDir()}) resumed.width, resumed.height = 90, 30 - if !strings.Contains(plain(frame(resumed)), "esc back · ctrl+c interrupts or quits") { + if !strings.Contains(plain(frame(resumed)), "esc interrupts · ctrl+c quits") { t.Fatalf("a resumed session lost its opening line:\n%s", plain(frame(resumed))) } if !strings.Contains(helpText("", chordSpelling{}), "alt+enter") { @@ -691,7 +690,7 @@ func TestSlashOpensTheCommandListFiltersItAndRunsIt(t *testing.T) { t.Fatalf("a bare slash has to offer everything (%d hits)", len(a.menu.hits)) } drawn := plain(strings.Join(a.overlayRows(a.width, a.overlayHeight()), "\n")) - if !strings.Contains(drawn, "/ask") || !strings.Contains(drawn, "ask here on home") { + if !strings.Contains(drawn, "/attach") || !strings.Contains(drawn, "attach") { t.Fatalf("the list draws a name and a line about it:\n%s", drawn) } @@ -874,7 +873,7 @@ func TestHelpPrintsTheAliasesFromTheSameTable(t *testing.T) { "also /exit /q", "also /?", "/rewind", - "go back to an earlier point", + "go back to an earlier point · esc esc takes back the last", "also /undo /back", } { if !strings.Contains(text, want) { diff --git a/internal/tui3/tabreopen_recovery_test.go b/internal/tui3/tabreopen_recovery_test.go index 07c291f1f2..aaa1db1e7f 100644 --- a/internal/tui3/tabreopen_recovery_test.go +++ b/internal/tui3/tabreopen_recovery_test.go @@ -52,7 +52,7 @@ func TestReopenSharedRemoteTabKeepsRefusalForRetryFromHome(t *testing.T) { } } -func TestEscapeStaysHomeAndReopenRestoresTheLastClosedTab(t *testing.T) { +func TestReopenSkipsTheLastClosedTabAfterEscapeAlreadyReturnedToIt(t *testing.T) { a := reopenApp(t) var closed []string for i := 0; i < 3; i++ { @@ -63,11 +63,11 @@ func TestEscapeStaysHomeAndReopenRestoresTheLastClosedTab(t *testing.T) { t.Fatal("closing the final tab did not go Home") } drive(t, a, key("esc")) - if !a.at(pageHome) || a.file != closed[2] { - t.Fatal("Escape left Home or changed the underlying conversation") + if a.pageShowing() || a.file != closed[2] { + t.Fatal("Escape did not return to the same underlying conversation") } drive(t, a, reopenPress()) - if a.at(pageHome) || a.file != closed[2] || a.tabShut[a.convKey(closed[2])] { - t.Fatal("reopen did not restore the last closed tab from Home") + if a.file != closed[1] || a.tabShut[a.convKey(closed[2])] { + t.Fatal("reopen spent a key on the already-visible tab instead of the prior closure") } } diff --git a/internal/tui3/taskphone.go b/internal/tui3/taskphone.go index e57d74caaa..88c7ca8cc4 100644 --- a/internal/tui3/taskphone.go +++ b/internal/tui3/taskphone.go @@ -177,12 +177,12 @@ func (a *app) stripPhonePress(width int) (tea.Cmd, bool) { // two-cell lead ([tasksBareLead]) already sits in front of. const taskSheetPhoneIndent = 2 -// taskSheetBar is the roster page's foot at [tierPhone]: an `esc home` band a thumb +// taskSheetBar is the roster page's foot at [tierPhone]: an `esc close` band a thumb // leaves by, in place of the key legend a keyboard reads ([tasksPlace.hint]). // It is the record card's own bar shape ([phoneBar]) — one target here, because // filtering the page is done by typing and there is no toggle to give a band to. func (a *app) taskSheetBar(width int) (string, []hudSpan) { - back := homeDoorWord + back := mapCloseWords if a.taskSheetFiltering() { back = tasksClearFilterWord } @@ -190,7 +190,7 @@ func (a *app) taskSheetBar(width int) (string, []hudSpan) { } // taskSheetBarPress follows the same back action as Escape: clear a filter first, -// then return to Home. +// then return to the conversation. func (a *app) taskSheetBarPress(x int) tea.Cmd { width, _ := a.size() _, spans := a.taskSheetBar(width) diff --git a/internal/tui3/taskphone_test.go b/internal/tui3/taskphone_test.go index 990ff3e73c..7fdc242d36 100644 --- a/internal/tui3/taskphone_test.go +++ b/internal/tui3/taskphone_test.go @@ -214,7 +214,7 @@ func TestThePhoneRosterFootIsABackBarToTheConversation(t *testing.T) { lines := taskSheetLines(a) foot := lines[len(lines)-1] - if !strings.Contains(foot, homeDoorWord) { + if !strings.Contains(foot, mapCloseWords) { t.Fatalf("the phone foot is not a back bar:\n%q", foot) } // And the key legend a keyboard reads is gone from it. @@ -228,8 +228,8 @@ func TestThePhoneRosterFootIsABackBarToTheConversation(t *testing.T) { } drive(t, a, tea.MouseClickMsg{X: 2, Y: y, Button: tea.MouseLeft}) drive(t, a, tea.MouseReleaseMsg{X: 2, Y: y, Button: tea.MouseLeft}) - if !a.at(pageHome) { - t.Fatal("tapping esc home did not return to Home") + if !a.at(pageNone) { + t.Fatal("tapping esc close did not return to the conversation") } } diff --git a/internal/tui3/tui3_test.go b/internal/tui3/tui3_test.go index b07ec1b432..15db1a8c74 100644 --- a/internal/tui3/tui3_test.go +++ b/internal/tui3/tui3_test.go @@ -1837,14 +1837,14 @@ func TestTheSameNoteTwiceRunningIsOneNote(t *testing.T) { } } -func TestCtrlCInterruptsThenCloses(t *testing.T) { +func TestEscInterruptsAndCtrlCCloses(t *testing.T) { agent := &fakeAgent{model: "m", turns: [][]session.Event{{ text(session.EventTextDelta, "thinking about it"), }}} a := newTestApp(agent) typeLine(t, a, "long one") - drive(t, a, key("ctrl+c")) + drive(t, a, key("esc")) if agent.stops != 1 { t.Fatalf("ctrl+c did not interrupt (%d)", agent.stops) } diff --git a/internal/tui3/watching.go b/internal/tui3/watching.go index 3d78b7219e..f20cb07460 100644 --- a/internal/tui3/watching.go +++ b/internal/tui3/watching.go @@ -90,7 +90,7 @@ func (a *app) watching() bool { // weaker claim of the two and the one that stays true either way. // // IT DOES NOT ADVERTISE THE DOOR HOME, and that is not an omission. The foot of -// the frame already says `esc back` in the hint slot ([homeDoorWord]), +// the frame already says `space space home` in the hint slot ([homeDoorWord]), // the gesture still works from here, and a second copy of it in this line would // be the surface teaching one door in two places. func (a *app) watchWord() string { @@ -137,16 +137,48 @@ func (a *app) watchBar(width int) []string { // send keys, which become the take-back, and a character typed into a box that // is not on the frame. // -// Escape falls through to the shared back navigation; characters cannot edit -// the hidden composer or trigger navigation in a watching window. +// THE DOOR HOME IS COUNTED HERE RATHER THAN LET THROUGH, and that is worth the +// four lines it costs. [app.homeGesture] reads two consecutive spaces out of the +// BOX, and this register has no box on the frame — so letting the space fall +// through while swallowing the letters turned a typed sentence into a run of +// spaces and opened home on the first word with two in it. Driven over a real +// connection, `this should be swallowed` walked straight out of the +// conversation. The gesture is the same gesture; it is simply counted where the +// keys actually are, and any other key ends the run exactly as a letter in the +// box would. func (a *app) watchKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { switch msg.String() { case "enter", standMarkKey: + a.watchSpaces = 0 return a.takeKeyboard(), true case "alt+enter", "ctrl+j": + // A newline into a box nobody can see is nothing at all. + a.watchSpaces = 0 return nil, true } - return nil, msg.Key().Text != "" + text := msg.Key().Text + if text == "" { + // Not a character: the arrows, the scroll keys, copy mode, the places. + // They are not this register's business and they end the run. + a.watchSpaces = 0 + return nil, false + } + if text != " " { + a.watchSpaces = 0 + return nil, true + } + // A SPACE ONLY COUNTS OVER AN EMPTY DRAFT, which is the condition the + // gesture has always had: a person with words already in the box meant a + // space in their sentence, and this window is still holding those words. + if !a.input.empty() { + return nil, true + } + a.watchSpaces++ + if a.watchSpaces >= 2 && a.homeDoorOpen() { + a.watchSpaces = 0 + return a.openHome(), true + } + return nil, true } // takeKeyboard asks the far machine for the keyboard back. diff --git a/internal/tui3/watching_test.go b/internal/tui3/watching_test.go index cdde3f618c..ac6eb0bd2d 100644 --- a/internal/tui3/watching_test.go +++ b/internal/tui3/watching_test.go @@ -159,14 +159,13 @@ func TestAWatcherCanStillWalkAwayToHome(t *testing.T) { t.Fatal("the spaces inside a typed sentence opened home") } - // Spaces are swallowed too; Escape is the shared back key. + // And two CONSECUTIVE spaces are still the door, counted where the keys are. if !a.homeDoorOpen() { t.Skip("home is not reachable from this test surface") } drive(t, a, key(" "), key(" ")) - a.key(key("esc")) if !a.at(pageHome) { - t.Fatal("Escape at a watcher did not open home") + t.Fatal("two spaces at a watcher did not open home") } } diff --git a/internal/tui3/welcome.go b/internal/tui3/welcome.go index ee998f9508..0cf5206e97 100644 --- a/internal/tui3/welcome.go +++ b/internal/tui3/welcome.go @@ -199,7 +199,7 @@ func (a *app) openWelcome() { // dismissWelcome puts the unit away for good, and says the two keys that leave. // -// THE EXIT IS TAUGHT AFTER THE ENTRANCE. `esc back · ctrl+c interrupts or quits` +// THE EXIT IS TAUGHT AFTER THE ENTRANCE. `esc interrupts · ctrl+c quits` // used to be the first line of every session, drawn above a greeting whose whole // job was to get somebody to type their first sentence — a way out, offered // before the way in. So while the unit is up the transcript carries nothing, diff --git a/internal/tui3/wiring_test.go b/internal/tui3/wiring_test.go index 3aaa93e91e..62f6c48c47 100644 --- a/internal/tui3/wiring_test.go +++ b/internal/tui3/wiring_test.go @@ -594,7 +594,7 @@ func TestAnInterruptDropsWhatWasQueued(t *testing.T) { } _ = agent - drive(t, a, key("ctrl+c")) + drive(t, a, key("esc")) if len(a.follows) != 0 { t.Fatal("the interrupt kept the queue the session just dropped") } @@ -661,7 +661,7 @@ func TestFollowUpsDrainInOrderBeforeTheParkedMessage(t *testing.T) { // M10: Esc drops both session-owned queues, closes every follow-up stream, and // drops the surface-owned parked queue before the stopped stream ends. -func TestCtrlCClosesQueuedStreamsAndDropsTheParkedTurn(t *testing.T) { +func TestEscClosesQueuedStreamsAndDropsTheParkedTurn(t *testing.T) { agent, a := wired([]session.Event{text(session.EventTextDelta, "working")}) typeLine(t, a, "the first turn") settleAsk(a) @@ -672,17 +672,17 @@ func TestCtrlCClosesQueuedStreamsAndDropsTheParkedTurn(t *testing.T) { parkLine(t, a, "the parked message") firstTurn := a.turn - drive(t, a, key("ctrl+c")) + drive(t, a, key("esc")) if len(a.follows) != 0 { - t.Fatalf("Ctrl+C left %d follow-ups on the surface", len(a.follows)) + t.Fatalf("Esc left %d follow-ups on the surface", len(a.follows)) } for index, stream := range agent.followStreams { if _, open := <-stream; open { - t.Fatalf("follow-up stream %d remained open after Ctrl+C", index) + t.Fatalf("follow-up stream %d remained open after Esc", index) } } if len(agent.sent) != 1 || len(a.parks) != 0 { - t.Fatalf("Ctrl+C did not drop the parked message: sent=%q parks=%+v", agent.sent, a.parks) + t.Fatalf("Esc did not drop the parked message: sent=%q parks=%+v", agent.sent, a.parks) } agent.finish() From f3071a5fc27297d9034876ac1ce6557a6784e659 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:02:13 -0400 Subject: [PATCH 14/39] Format restored rewind state fields --- internal/tui3/app.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/tui3/app.go b/internal/tui3/app.go index b61bfc7f64..e718e3f69f 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -7194,7 +7194,6 @@ func (a *app) slash(line string) tea.Cmd { // the shape every choice row on this surface refuses in. return a.runEffort(rest) - case "task": return a.runTaskCommand(rest) From 28bbee15058c4389e97be4d6d36a9d069dbb81e1 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:03:19 -0400 Subject: [PATCH 15/39] Align restored Home instructions with current panels and controls --- internal/manual/chat/asking-from-home.md | 2 +- internal/manual/chat/home.md | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/internal/manual/chat/asking-from-home.md b/internal/manual/chat/asking-from-home.md index bfe2061a6f..54ca91ea7c 100644 --- a/internal/manual/chat/asking-from-home.md +++ b/internal/manual/chat/asking-from-home.md @@ -13,7 +13,7 @@ Yes. Type it on the home screen, press `↑` once — which lands on the row spe enter starts a new conversation and sends this · ↑ ask here · ↑↑ pick a match · alt+p project · alt+e effort · alt+a approvals · esc clear ``` -What you get is **a row at the top of home's `threads` panel and a pane holding the exchange**. The row +What you get is **a row in home's conversation list and a pane holding the exchange**. The row stays there — with what the errand is doing written in its tail — until the errand is finished and you have read what it came to. The pane is the exchange itself: what you said, the reply as it streams, one line per tool call, and the card when one arrives. On an diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index 7783bebd85..9628839450 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -1380,7 +1380,7 @@ standing choice. The selected project remains pinned. Neither cell is drawn on a **Home and conversations share the model, effort and approvals controls.** Other full-screen places have no general conversation message box; return Home -with Escape to start a conversation. +with `space` `space` (or `alt+1`) to start a conversation. **`/folder` is the third door onto the same pin, and it is the one that shows you the disk.** Typed on home — bare, or with a path after it — it opens the folder browser with the title @@ -1407,7 +1407,8 @@ and opens home. So a space you actually wanted is never eaten: space then `x` le **Wherever the door is drawn, two spaces open it.** That includes a box holding only blank lines, from a `ctrl+j` or an `alt+enter` you did not mean. It also includes the other places: the same two spaces, typed into a place's own empty box — the tasks roster's filter, -the memory filter, the search and spend composers — open home from there. The door still +the memory filter and the search query — open home from there. Places without a box, +such as spend and standing, count the two spaces directly. The door still loses to a space that already means something where you are standing: on the settings panel space is the row's `activate` verb, inside a task's record `space` pages the card, and on home itself two spaces type into home's own box. @@ -1418,14 +1419,13 @@ with none, and over `--host` — where what opens is the **far machine's** home. ## What does pressing space twice do — the home door at the foot of a conversation -When the box is empty, the keys row under the box — the last row of the frame — reads -exactly: +When the box is empty, the keys row under the box ends with: ``` / commands · space space home ``` -That is the whole advertisement. It costs no extra row — it is the keys row the frame +The model controls and chats shortcut can precede it. It costs no extra row — it is the keys row the frame already has — and it **vanishes the moment you type anything**, because it is a door and not decoration. It also goes while a turn is running, where the same row has something more urgent to say (`esc interrupt`); the gesture still works then, it is just not being From 2407055a2814d2f35d4637ef1ee8a9f92a65ed6d Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:09:48 -0400 Subject: [PATCH 16/39] Cover restored Home controls in real terminals and align remaining hint checks --- internal/e2e/home_restore_e2e_test.go | 68 +++++++++++++++++++++++++++ internal/tui3/payload_test.go | 4 +- internal/tui3/stripword_test.go | 2 +- internal/tui3/switch_test.go | 2 +- 4 files changed, 72 insertions(+), 4 deletions(-) create mode 100644 internal/e2e/home_restore_e2e_test.go diff --git a/internal/e2e/home_restore_e2e_test.go b/internal/e2e/home_restore_e2e_test.go new file mode 100644 index 0000000000..de8ea57cf5 --- /dev/null +++ b/internal/e2e/home_restore_e2e_test.go @@ -0,0 +1,68 @@ +//go:build e2e + +package e2e + +import ( + "os/exec" + "strconv" + "strings" + "testing" + "time" +) + +// Home's navigation and submission choices must work before a provider is +// connected. This drives the shipped binary without sending a model request. +func TestHomeRestoredNavigationNoModel(t *testing.T) { + if _, err := exec.LookPath("tmux"); err != nil { + t.Skip("no tmux on PATH") + } + for _, width := range []int{180, 120, 44} { + t.Run(strconv.Itoa(width), func(t *testing.T) { + home := newHome(t, nil) + seedProject(t, home, "alpha", 0, time.Minute) + ws := newWorkspace(t, "navigation", false) + r := startFresh(t, "home_restore", home, ws, width, 40, "chat", "--no-host") + r.skipSetup(t) + r.waitFor(20*time.Second, say(t, "placeRestWord")) + + r.keys("Escape") + r.waitFor(15*time.Second, say(t, "homeDoorWord")) + r.keys("Space", "Space") + r.waitFor(15*time.Second, say(t, "placeRestWord")) + + r.lit("Seed") + screen := r.waitFor(15*time.Second, say(t, "homeAskHereWord"), say(t, "homeStartWord"), "Seed Alpha") + match := strings.Index(screen, "Seed Alpha") + ask := strings.Index(screen, say(t, "homeAskHereWord")) + start := strings.Index(screen, say(t, "homeStartWord")) + box := strings.LastIndex(screen, "› Seed") + if !(match < ask && ask < start && start < box) { + t.Fatalf("search, ask, new conversation and box are out of order:\n%s", screen) + } + if width > 44 { + r.keys("Up") + r.waitFor(10*time.Second, "enter asks this here") + r.keys("Down") + r.waitFor(10*time.Second, "enter starts a new conversation") + } + t.Logf("restored choices at %d columns:\n%s", width, screen) + + // Clearing the draft and closing Home are separate Escape presses. + r.keys("Escape") + r.waitFor(10*time.Second, say(t, "placeRestWord")) + r.keys("Escape") + r.waitFor(10*time.Second, say(t, "homeDoorWord")) + r.keys("Escape") + if screen := r.capture(); strings.Contains(screen, say(t, "placeRestWord")) { + t.Fatalf("Escape reopened Home:\n%s", screen) + } + r.keys("Space", "Space") + r.waitFor(10*time.Second, say(t, "placeRestWord")) + r.lit("/ask") + screen = r.waitFor(10*time.Second, "/task") + if strings.Contains(screen, "ask here on home") { + t.Fatalf("the removed /ask command is still offered:\n%s", screen) + } + }) + } +} diff --git a/internal/tui3/payload_test.go b/internal/tui3/payload_test.go index e627f602de..a586e8df75 100644 --- a/internal/tui3/payload_test.go +++ b/internal/tui3/payload_test.go @@ -202,7 +202,7 @@ func TestTheLegendsChordReadsAboveItsExplanation(t *testing.T) { a.state = stateWorking line := a.hintRow(a.width) - if !lifted(a.pal, line, "ctrl+c") { + if !lifted(a.pal, line, "esc") { t.Fatalf("the keys row draws its chord at the weight of its prose:\n%q", line) } if !dimmed(a.pal, line, " interrupt") { @@ -308,7 +308,7 @@ func TestTheHintGrammarReadsEveryHintThisSurfaceWrites(t *testing.T) { want = append(want, "ctrl+g") } parts = append(parts, stop) - want = append(want, "ctrl+c") + want = append(want, "esc") cases = append(cases, hintGrammarCase{hint: strings.Join(parts, hintSegment), want: want}) } } diff --git a/internal/tui3/stripword_test.go b/internal/tui3/stripword_test.go index 3079ad7a54..23719aa7dd 100644 --- a/internal/tui3/stripword_test.go +++ b/internal/tui3/stripword_test.go @@ -108,7 +108,7 @@ func TestATeachingPagesFootOffersNoVerbOverABodyWithNoRows(t *testing.T) { foot := placeTailed(page.foot(a)) want := wayOut if page.what == "tasks" { - want += " home" + want += " close" } for _, clause := range page.bad { if strings.Contains(foot, clause) { diff --git a/internal/tui3/switch_test.go b/internal/tui3/switch_test.go index e8255ded6d..7dd6f6087f 100644 --- a/internal/tui3/switch_test.go +++ b/internal/tui3/switch_test.go @@ -190,7 +190,7 @@ func TestASwitchKeepsParkedMessagesStructuredBesideTheDraft(t *testing.T) { t.Fatalf("the structured queue came back as %+v", a.parks) } drawn := plain(strings.Join(a.parkedRows(120), "\n")) - for _, want := range []string{"and check the tests", "shot.png", "then push", "wait for this answer", "ctrl+c stops and drops"} { + for _, want := range []string{"and check the tests", "shot.png", "then push", "wait for this answer", "esc stops and drops"} { if !strings.Contains(drawn, want) { t.Fatalf("the waiting block is missing %q:\n%s", want, drawn) } From d24b68c1f498be515020aa92868540d826a0bf47 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:13:32 -0400 Subject: [PATCH 17/39] Exercise both Home submission routes and stopping through the built terminal --- internal/e2e/home_restore_e2e_test.go | 38 +++++++++++++++++++++++++++ internal/e2e/stopbound_e2e_test.go | 5 ++-- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/internal/e2e/home_restore_e2e_test.go b/internal/e2e/home_restore_e2e_test.go index de8ea57cf5..9440d2db0f 100644 --- a/internal/e2e/home_restore_e2e_test.go +++ b/internal/e2e/home_restore_e2e_test.go @@ -66,3 +66,41 @@ func TestHomeRestoredNavigationNoModel(t *testing.T) { }) } } + +// Both submission rows cross the real surface-to-engine boundary, with a local +// endpoint providing a fixed answer so no provider account is needed. +func TestHomeRestoredSubmissionDoorsWithStub(t *testing.T) { + if _, err := exec.LookPath("tmux"); err != nil { + t.Skip("no tmux on PATH") + } + stub := &stopStub{kind: parkOnStream} + base := serveStopStub(t, stub) + stub.stop() + home := newHome(t, map[string]any{"model.talk": "stub/bounded"}) + seedProject(t, home, "alpha", 0, time.Minute) + ws := newWorkspace(t, "submission", false) + r := startWithEnv(t, + []string{"OPENROUTER_API_KEY=stub-key", "CODEAF_BASE_URL=" + base, "CODEAF_PROFILE_DIR="}, + "home_submission", home, ws, 180, 40, "chat", "--no-host", "--one-model") + r.skipSetup(t) + r.waitFor(20*time.Second, say(t, "placeRestWord")) + r.lit("answer in the home pane") + r.waitFor(10*time.Second, say(t, "homeStartWord")) + r.keys("Up") + r.waitFor(10*time.Second, "enter asks this here") + r.keys("Enter") + pane := r.waitFor(20*time.Second, stopStubDone, say(t, "exchangeBack")) + t.Logf("ask here received the endpoint's answer in its own pane:\n%s", pane) + r.keys("Escape") + r.waitFor(10*time.Second, say(t, "placeRestWord")) + r.lit("answer in a new conversation") + r.waitFor(10*time.Second, say(t, "homeStartWord")) + r.keys("Enter") + conversation := r.waitFor(20*time.Second, stopStubDone, "idle", "› answer in a new conversation") + if strings.Contains(conversation, say(t, "exchangeBack")) { + t.Fatalf("the new-conversation row left the answer in an ask pane:\n%s", conversation) + } + t.Logf("the default row opened a conversation and received the answer:\n%s", conversation) + r.keys("Space", "Space") + r.waitFor(10*time.Second, say(t, "placeRestWord")) +} diff --git a/internal/e2e/stopbound_e2e_test.go b/internal/e2e/stopbound_e2e_test.go index a9518a4e4d..fe5b795f13 100644 --- a/internal/e2e/stopbound_e2e_test.go +++ b/internal/e2e/stopbound_e2e_test.go @@ -465,8 +465,9 @@ func waitUntilParked(t *testing.T, rig *rig, stub *stopStub) { for time.Now().Before(deadline) { switch stub.kind { case parkOnStream: - // The second request is out and its text is arriving. - if strings.Contains(rig.capture(), stopStubStreaming) { + // The live summary can omit the sentence-ending period. The same + // words still prove that the second request is streaming on screen. + if strings.Contains(rig.capture(), strings.TrimSuffix(stopStubStreaming, ".")) { return } case parkOnPipe: From 5561960483e3665df5826cd9825e441ce5af38c8 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:16:49 -0400 Subject: [PATCH 18/39] Document restored Home controls for #1388 --- .../unreleased/1388-restore-home-controls.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 docs/changes/unreleased/1388-restore-home-controls.md diff --git a/docs/changes/unreleased/1388-restore-home-controls.md b/docs/changes/unreleased/1388-restore-home-controls.md new file mode 100644 index 0000000000..965095e6ae --- /dev/null +++ b/docs/changes/unreleased/1388-restore-home-controls.md @@ -0,0 +1,14 @@ +--- +kind: changed +title: Home returns to double-space navigation and the ask-here choice +pr: 1388 +surface: [chat, docs] +invalidates: + - "Escape navigated outward until Home and two spaces only typed text. Two spaces in an empty box open Home again; Escape closes places into the conversation, interrupts its running answer, and double-Escape opens inline rewind." + - "Home showed only search results above the seam and /ask selected a Home exchange. The ask-here and start-a-new-conversation rows are back below the results; Enter defaults to a conversation, Up then Enter asks here, and /ask is removed." +--- + +Restore the two interactions changed in #1071 while keeping subsequent command-menu, +effort-shortcut, question-row, compact-layout and waiting-message fixes. The manual +and terminal test recipes follow the restored keys. Deterministic tmux coverage drives +the built binary at three widths and checks both submission routes with a local endpoint. From 89addc52e4f899f94d1e7fff26dde950555e355e Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:31:05 -0400 Subject: [PATCH 19/39] Validate restored Home with live models and await terminal cleanup --- internal/e2e/stopbound_e2e_test.go | 70 ++++++++++++++++++++++++------ internal/e2e/tmux_test.go | 25 +++++++++++ internal/e2e/tui_e2e_test.go | 33 +++++--------- 3 files changed, 91 insertions(+), 37 deletions(-) diff --git a/internal/e2e/stopbound_e2e_test.go b/internal/e2e/stopbound_e2e_test.go index fe5b795f13..731e41f2b0 100644 --- a/internal/e2e/stopbound_e2e_test.go +++ b/internal/e2e/stopbound_e2e_test.go @@ -310,23 +310,24 @@ func runRealModelBoundedStop(t *testing.T) { }) rig := start(t, "stopreal", home, workspace, 120, 40) - rig.lit("use the read tool on " + pipe + " and tell me what is in it") + // Explain the deliberate wait: a model otherwise correctly refuses to + // open a FIFO, so the test never reaches the stop it is supposed to time. + rig.lit("This is a controlled cancellation test. Use the read tool directly on " + pipe + + ". It is an intentional FIFO with no writer. Blocking is expected: I will press Escape to cancel it. Do not inspect it with bash or avoid the read; start that read now.") rig.keys("Enter") - // THE TURN HAS TO BE GENUINELY INSIDE THE CALL BEFORE THE KEY IS PRESSED. - // Waiting for the word `read` is not enough — it is in the sentence the person - // typed, so it is on screen before the model has decided anything, and a run - // that pressed esc there would be timing the stop of a turn that was between - // steps. The status line's own phase clause is the honest evidence: it reads - // `running read · Ns` only while the call is executing (internal/tui3's - // phase segment), and a call parked on a pipe with no writer never leaves it. - rig.waitFor(modelPatience, "running read") - // And it is STILL there several seconds later, which is what tells a call that - // is stuck apart from one that is merely slow. + // The journal identifies the exact read and its completion. The live + // footer can summarize it as "working" after briefly saying "running read". + deadline := time.Now().Add(modelPatience) + for !pendingPipeRead(t, home, pipe) { + if time.Now().After(deadline) { + t.Fatalf("the model never started the requested pipe read:\n%s", rig.capture()) + } + time.Sleep(pollEvery) + } time.Sleep(6 * time.Second) - screen := rig.capture() - if !strings.Contains(screen, "running read") { - t.Fatalf("the read call came back, so this run is not about an uncancellable wait:\n%s", screen) + if !pendingPipeRead(t, home, pipe) { + t.Fatalf("the read completed, so this run is not about an uncancellable wait:\n%s", rig.capture()) } pressed := time.Now() @@ -354,6 +355,47 @@ func runRealModelBoundedStop(t *testing.T) { rig.waitFor(modelPatience, "ready") } +// pendingPipeRead checks the journal rather than a transient status label, so +// the path in the user's prompt cannot satisfy the wait. +func pendingPipeRead(t *testing.T, home, pipe string) bool { + t.Helper() + for _, transcript := range sessionTranscripts(t, home) { + pending := map[string]bool{} + for _, line := range strings.Split(transcript, "\n") { + var entry struct { + ToolCalls []struct { + ID string `json:"id"` + Function struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"function"` + } `json:"toolCalls"` + Took struct { + CallID string `json:"callId"` + } `json:"took"` + ToolCallID string `json:"toolCallId"` + } + if json.Unmarshal([]byte(line), &entry) != nil { + continue + } + for _, call := range entry.ToolCalls { + var args struct { + Path string `json:"path"` + } + if call.Function.Name == "read" && json.Unmarshal([]byte(call.Function.Arguments), &args) == nil && args.Path == pipe { + pending[call.ID] = true + } + } + delete(pending, entry.Took.CallID) + delete(pending, entry.ToolCallID) + } + if len(pending) > 0 { + return true + } + } + return false +} + // runStoppedInTime is the scenario where the engine DOES let go: the surface's // wait ends inside the bound, nothing is detached, and the box is usable again. func runStoppedInTime(t *testing.T, stub *stopStub, name, ask string) { diff --git a/internal/e2e/tmux_test.go b/internal/e2e/tmux_test.go index f194889232..83570970ca 100644 --- a/internal/e2e/tmux_test.go +++ b/internal/e2e/tmux_test.go @@ -30,7 +30,9 @@ import ( "os" "os/exec" "path/filepath" + "strconv" "strings" + "syscall" "testing" "time" @@ -559,7 +561,30 @@ func (r *rig) kill() { return } r.dead = true + // Killing the tmux session sends a hangup but does not wait for codeaf. + // Its final writes must finish before testing removes the fixture home. + raw, _ := exec.Command("tmux", "display-message", "-p", "-t", r.name, "#{pane_pid}").Output() + pid, _ := strconv.Atoi(strings.TrimSpace(string(raw))) _ = exec.Command("tmux", "kill-session", "-t", r.name).Run() + if pid <= 0 { + return + } + for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline); { + if syscall.Kill(pid, 0) != nil { + return + } + time.Sleep(20 * time.Millisecond) + } + // A failed scenario may have left an intentional uninterruptible tool + // wait. This PID belongs to the test's own pane, never to another rig. + _ = syscall.Kill(pid, syscall.SIGKILL) + for deadline := time.Now().Add(2 * time.Second); time.Now().Before(deadline); { + if syscall.Kill(pid, 0) != nil { + return + } + time.Sleep(20 * time.Millisecond) + } + r.t.Errorf("the test terminal process %d did not exit before cleanup", pid) } // dump is the transcript this suite owes anybody reading a failure: the screen, diff --git a/internal/e2e/tui_e2e_test.go b/internal/e2e/tui_e2e_test.go index ebac806b72..64d03d92ef 100644 --- a/internal/e2e/tui_e2e_test.go +++ b/internal/e2e/tui_e2e_test.go @@ -96,7 +96,7 @@ const tuiShortRows = 14 func TestTUIE2E(t *testing.T) { requireTmuxAndKey(t) - t.Run("home_opens_on_launch_as_seven_panels", testHomeShape) + t.Run("home_opens_on_launch_with_recent_sessions", testHomeShape) t.Run("a_real_conversation_on_the_panels_and_its_search_card", testRealConversation) t.Run("ask_here_end_to_end", testAskHere) t.Run("the_firing_reaches_the_person", testFiringReachesThePerson) @@ -327,16 +327,8 @@ func testRefusedLanding(t *testing.T) { // ── 1 ─────────────────────────────────────────────────────────────────────── -// testHomeShape opens the product with five projects on the machine and reads -// the shape home has TODAY (docs/design/home-mission-control/DESIGN.md): seven -// panels under a four-word bar, every seeded conversation on `threads`, -// an empty panel keeping its heading and its whisper, the foot's three verbs, and -// the two doors in and out of the screen. -// -// WHAT THIS SUBTEST USED TO ASSERT AND NO LONGER CAN. It read one flat ranked -// list with a `what wants you first` section line and a fold at its foot, and -// before that a tree of projects under an `─ elsewhere` rule. Both went: what a -// person has at a glance now is one panel per question, so that is what is read. +// testHomeShape reads Home's current sessions, projects, spend, activity and +// scheduled panels, then drives the command and double-space routes back to it. func testHomeShape(t *testing.T) { home := newHome(t, nil) for i, name := range []string{"alpha", "beta", "gamma", "delta", "epsilon"} { @@ -348,21 +340,15 @@ func testHomeShape(t *testing.T) { screen := r.waitFor(20*time.Second, say(t, "placeRestWord"), say(t, "homePanelProjects")) t.Logf("home greeted on launch:\n%s", screen) - // EVERY PANEL IS ON THE PAGE. Forty rows is room for all seven at their + // EVERY PANEL IS ON THE PAGE. Forty rows is room for all five at their // floors in two columns, so a heading missing here is a panel the grid lost // rather than one a short frame squeezed out. - for _, name := range []string{"homeNeedsHeading", "homePanelProjects", + for _, name := range []string{"homePanelProjects", "homePanelRunning", "switcherSinceLeft", "homePanelSpend", "homePanelNext"} { if !strings.Contains(screen, say(t, name)) { t.Errorf("home has no %q panel:\n%s", say(t, name), screen) } } - // AN EMPTY PANEL WHISPERS. Nothing runs on a machine of seeded transcripts, - // so `running` keeps its heading and says what arrives there — never that it - // is empty. - if !strings.Contains(screen, say(t, "homeRunningWhisper")) { - t.Errorf("the empty `running` panel does not whisper %q:\n%s", say(t, "homeRunningWhisper"), screen) - } // THE BAR IS FOUR WORDS. Standing, memory and search are places reached by // command and by alt+5…7, and a bar that still named them is the seven-word // bar this wave retired. @@ -371,10 +357,10 @@ func testHomeShape(t *testing.T) { t.Errorf("the tab bar reads %q, want %q:\n%s", got, want, screen) } - // Saved history is searchable but is not an open tab on this launch. + // Home includes recent saved conversations, even before a tab opens them. for _, title := range []string{"Seed Alpha", "Seed Beta", "Seed Gamma", "Seed Delta", "Seed Epsilon"} { - if strings.Contains(screen, title) { - t.Errorf("unopened history appeared as a tab: %q", title) + if !strings.Contains(screen, title) { + t.Errorf("recent history is missing from Home: %q", title) } } // Home keeps the command door but omits the ordinary navigation hints. @@ -641,7 +627,8 @@ func testAskHere(t *testing.T) { // IS THE ORACLE for where the cursor is standing: the switcher's rows carry // no `›` lead of their own, and the one line that changes with the cursor is // the hint (internal/tui3's homeHint). - if !walkTo(r, say(t, "homeAnswerHint"), "Up") { + // The exchange follows the conversation row selected when Home opens. + if !walkTo(r, say(t, "homeAnswerHint"), "Down") { t.Fatalf("could not put the cursor back on the exchange row:\n%s", r.capture()) } r.keys("Enter") From b41b8c6c24221ed4fcb22f61d5401cc5db7d5c13 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:32:11 -0400 Subject: [PATCH 20/39] Check that populated Home sessions omit their empty hint --- internal/e2e/tui_e2e_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/e2e/tui_e2e_test.go b/internal/e2e/tui_e2e_test.go index 64d03d92ef..ab5c3ccf8f 100644 --- a/internal/e2e/tui_e2e_test.go +++ b/internal/e2e/tui_e2e_test.go @@ -349,6 +349,9 @@ func testHomeShape(t *testing.T) { t.Errorf("home has no %q panel:\n%s", say(t, name), screen) } } + if strings.Contains(screen, say(t, "homeRunningWhisper")) { + t.Errorf("populated sessions still show the empty-panel hint:\n%s", screen) + } // THE BAR IS FOUR WORDS. Standing, memory and search are places reached by // command and by alt+5…7, and a bar that still named them is the seven-word // bar this wave retired. From de0d935cdc7e9b9b18c8a865e48174d567208126 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:21:57 -0400 Subject: [PATCH 21/39] =?UTF-8?q?chat:=20twenty-three=20tips=20=E2=80=94?= =?UTF-8?q?=20/ask,=20/folder=20and=20the=20second=20/attach=20row=20come?= =?UTF-8?q?=20off?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner's read, item by item: - `/ask answers right here without opening a conversation` comes off ahead of the door it teaches. The command is untouched. - `/folder picks the folder codeaf works in` comes off because it was NOT TRUE. /folder never moves the directory codeaf stands in — that is fixed for the life of a conversation — it registers a directory the conversation is ABOUT, which is what a folder after /attach already does, through the same seam. The command, and /place and /dir with it, is untouched. - `/attach sends a file along with your message` becomes `/attach sends a file or folder with your message`, which is what that command has done since the folder branch landed. - `/attach lets you browse anywhere for files` comes off: one command, one row. - `/project sets the folder the next conversation opens in` becomes `/project sets the project folder for the next conversation`. - the steer-and-queue row becomes `using enter steers conversations · use ctrl+q to queue` — the owner wrote it with a hyphen, and a hyphen is not what joins two clauses on this surface. - `ctrl+t starts a fresh chat in this folder` becomes `ctrl+t starts a fresh chat in this project`. Twenty-six rows become twenty-three, and /project is the only row armed on home alone now that /ask's has gone. The five ids that only changed their words keep those ids; nothing is reused. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- internal/manual/chat/hints-and-tips.md | 48 +++++++++++---------- internal/manual/chat/home.md | 4 +- internal/manual/chat/places.md | 2 +- internal/tui3/hometip_test.go | 14 +++---- internal/tui3/notice.go | 58 +++++++++++--------------- 5 files changed, 61 insertions(+), 65 deletions(-) diff --git a/internal/manual/chat/hints-and-tips.md b/internal/manual/chat/hints-and-tips.md index 315e7c987a..953b638f49 100644 --- a/internal/manual/chat/hints-and-tips.md +++ b/internal/manual/chat/hints-and-tips.md @@ -6,7 +6,7 @@ The dim sentence directly above the rule over your message box, led by a bulb an by a small cross — `💡 ctrl+. sees every task this project has run ✕` — is a **tip**: one line naming a key or a command you have not used yet, and what it does. It reads the way every hint on this surface does: the key or the command first, then what it does. Home has -the same row over its own box, and the two rows draw from **one list** of twenty-six tips +the same row over its own box, and the two rows draw from **one list** of twenty-three tips (below). **In a conversation the row appears only once you have been quiet for a minute** — no key @@ -37,7 +37,7 @@ On a Mac the row says `opt` where the table below says `alt`, exactly as the key On home the tip is the dim row **directly above the rule** over the message box — the blank that separates the list from the rule, with one sentence written into its right end, led -by a bulb: `💡 /ask answers right here without opening a conversation ✕`. It is drawn only +by a bulb: `💡 /project sets the project folder for the next conversation ✕`. It is drawn only while the box is empty and nothing else is up — a letter in the box, the `/` list, the `@` list or a reply being read all take the row back — and it moves on to the next tip that is true for you on every road home (`esc` from a conversation, `/home`, `alt+1`, `tab`), in a @@ -92,7 +92,7 @@ later when the ring comes round. It jumps once and then takes its turn like the ## Every hint codeaf can show, and what makes each one go away -There are twenty-six, one list for both boxes. Each one says the moment it first appears +There are twenty-three, one list for both boxes. Each one says the moment it first appears and the gesture that retires it. The list is the program's own table (the surface refuses to build if the two disagree), so a tip you saw is on it word for word. @@ -112,9 +112,6 @@ build if the two disagree), so a tip you saw is on it word for word. a conversation. Retired when you run `/resume`. - `/standing keeps something always true` — once this directory has three or more earlier conversations. Retired when a standing order is made or the standing page opened. -- `/ask answers right here without opening a conversation` — on home, whenever home's ask - door is there (it is the one tip that is only true on home). Retired the first time - `/ask` or `alt+enter` sends something from home. - `/task starts work you can walk away from` — after the first exchange. Retired when `/task` is typed, bare or with a brief. - `ctrl+enter sends your message as something to keep true` — retired when a standing @@ -128,15 +125,11 @@ build if the two disagree), so a tip you saw is on it word for word. - `type @ to quickly attach files in the current project` — retired when the `@` list opens. -- `/attach sends a file along with your message` — retired when a file goes on the tray by - path or the file browser opens. -- `/project sets the folder the next conversation opens in` — on home only, since that is +- `/attach sends a file or folder with your message` — retired when a file or a folder goes + on by path, or the browser opens. +- `/project sets the project folder for the next conversation` — on home only, since that is the only screen `/project` works on. Retired when `/project` takes a folder, by a path after it or on the browser it opens. -- `/folder picks the folder codeaf works in` — retired when the folder chooser opens, from - a conversation or aimed at home's target. -- `/attach lets you browse anywhere for files` — retired by the same gesture as - the other `/attach` tip. - `/export writes the current conversation to a file` — after two exchanges. Retired when an export lands. @@ -150,13 +143,13 @@ build if the two disagree), so a tip you saw is on it word for word. **Steering a running answer** -- `using enter stops and steers conversations, use ctrl+q to queue` — after the first +- `using enter steers conversations · use ctrl+q to queue` — after the first exchange. Retired the first time you queue a message. It was two rows until 2026-09-22 — one for the steer and one for the queue — and the owner folded them into one. **Moving around** -- `ctrl+t starts a fresh chat in this folder` — retired when the new-chat page opens. +- `ctrl+t starts a fresh chat in this project` — retired when the new-chat page opens. **Memory, accounts and the rest** @@ -168,12 +161,25 @@ build if the two disagree), so a tip you saw is on it word for word. `ctrl+b freezes the screen so you can read and copy from it` held for one build on 2026-09-22, and `ask for a picture, a voiceover, music or a video` before that.) -**Four rows came off on 2026-09-22**, on the owner's read of the whole list: `alt+3 shows -what this machine has spent, by the day`, `alt+1 to alt+7 jump straight to a place`, -`/search finds anything ever said on this machine` and `/subharness lists the programs you -can run`. All four name doors the tab bar or the `/` list already puts in front of you, -which is the same argument that kept `alt+p`, `alt+e` and `/` off the list in the first -place. The features are unchanged; only the tips about them are gone. +**Seven rows came off on 2026-09-22**, over two reads of the whole list, and **every one of +the commands they named still works** — only the tips about them are gone. + +- `alt+3 shows what this machine has spent, by the day`, `alt+1 to alt+7 jump straight to a + place`, `/search finds anything ever said on this machine` and `/subharness lists the + programs you can run` name doors the tab bar or the `/` list already puts in front of + you, which is the argument that kept `alt+p`, `alt+e` and `/` off the list in the first + place. +- `/ask answers right here without opening a conversation` came off ahead of the door it + taught: `/ask` is on its way out, and a tip is for something you will still have + tomorrow. It was the only tip that was true on home alone until `/project` took that + place. +- `/folder picks the folder codeaf works in` came off because **it was not true**. `/folder` + never moves the directory codeaf is standing in — that is fixed for the life of a + conversation — it registers a directory the conversation is *about*, which is exactly + what a folder after `/attach` does, through the same door. So `/attach`'s row says "a + file or folder" now and this one is gone. +- `/attach lets you browse anywhere for files` was a second row about one command, which + is one row too many. Unless a line above says otherwise, a tip is true from the first minute on home and after the first exchange in a conversation. diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index 632823e8a5..ef15d67cc8 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -1387,8 +1387,8 @@ The double-space binding has been removed. Spaces type normally in message boxes ## The dim sentence above the rule on home — what is that tip over the box, why did it change The one dim line directly above the rule over home's box is a **tip**: one sentence naming a -key or a command you have not used yet, and what it does — `/ask answers right here without -opening a conversation`, `ctrl+t starts a fresh chat in this folder`. It is drawn only while +key or a command you have not used yet, and what it does — `/project sets the project folder +for the next conversation`, `ctrl+t starts a fresh chat in this project`. It is drawn only while the box is empty and nothing else is up, it moves on to the next tip every time you come to home and every two minutes at rest, and each tip goes away for good the first time you do what it names. It sits at the right, led by a bulb and closed by a small cross: clicking it diff --git a/internal/manual/chat/places.md b/internal/manual/chat/places.md index db19da729c..9d2edab650 100644 --- a/internal/manual/chat/places.md +++ b/internal/manual/chat/places.md @@ -197,7 +197,7 @@ The line over home's box is the same shape as the line over a conversation's own box: ``` - 💡 /ask answers right here without opening a conversation ✕ + 💡 /project sets the project folder for the next conversation ✕ ─ glm-5.3-flash:auto · ◇ asks ────────────────────────────────────────────────────── › type to search or start something new alt+p project · alt+e effort · alt+a approvals · / commands project: ~/src/parser diff --git a/internal/tui3/hometip_test.go b/internal/tui3/hometip_test.go index 7d14359826..e8b679bebe 100644 --- a/internal/tui3/hometip_test.go +++ b/internal/tui3/hometip_test.go @@ -247,11 +247,11 @@ func TestEveryTipIsOnTheManualPage(t *testing.T) { } } -// The cut was thirty, /project made it thirty-one, and the owner's read of the -// whole list took it to twenty-six. There is ONE set: every hint draws on both -// boxes, a news row on neither, and a row filed under home's slot does not -// build. -func TestTheTableIsTwentySixHintsAndEveryOneDrawsOnBothBoxes(t *testing.T) { +// The cut was thirty, /project made it thirty-one, and two reads of the whole +// list by the owner took it to twenty-three. There is ONE set: every hint draws +// on both boxes, a news row on neither, and a row filed under home's slot does +// not build. +func TestTheTableIsTwentyThreeHintsAndEveryOneDrawsOnBothBoxes(t *testing.T) { hints := 0 for _, n := range notices { if n.slot != slotHint { @@ -265,8 +265,8 @@ func TestTheTableIsTwentySixHintsAndEveryOneDrawsOnBothBoxes(t *testing.T) { t.Errorf("hint %q draws in the transcript", n.id) } } - if hints != 26 { - t.Fatalf("the table holds %d hints, want 26 — the cut is deliberate, and the manual page counts them", hints) + if hints != 23 { + t.Fatalf("the table holds %d hints, want 23 — the cut is deliberate, and the manual page counts them", hints) } news := notice{id: "noted", slot: slotNote, armed: ready, text: "x"} if news.draws(slotHint) || news.draws(slotHome) || !news.draws(slotNote) { diff --git a/internal/tui3/notice.go b/internal/tui3/notice.go index d90743054c..b1e2947fb7 100644 --- a/internal/tui3/notice.go +++ b/internal/tui3/notice.go @@ -297,10 +297,6 @@ var ( // spoken is a conversation that has had at least one exchange: a tip about // steering or queueing over an answer means nothing before one has arrived. spoken = func(a *app) bool { return a.turn >= 1 } - // askable is home's own door standing — the errand builder a launch may or - // may not hand the surface (homeexchange.go's [app.askHereWith]) — and the - // person standing on home, where the sentence it arms is true. - askable = func(a *app) bool { return a.errand != nil && a.at(pageHome) } // onHome is a tip about a command home is the only screen for: it is armed // while home is in front and stands down the moment it is not, so the one // list can hold a sentence that would be a lie over a conversation's box. @@ -313,13 +309,16 @@ var ( // and the page say the same words — and notice_test.go holds the page to every // line here, so the table cannot say a thing the manual does not. // -// TWENTY-SIX ROWS, AND EVERY CUT WAS DELIBERATE. A survey of the surface on +// TWENTY-THREE ROWS, AND EVERY CUT WAS DELIBERATE. A survey of the surface on // 2026-09-21 turned up forty-eight lines worth saying; thirty of those shipped, // /project made thirty-one when it became a command of its own on 2026-09-22, -// and the owner's read of the whole list that same day took it to twenty-six: -// `alt+3`, `alt+1`–`alt+7`, `/search` and `/subharness` came off as rows the -// foot or the tab bar already teaches, and the two lines about a running -// answer became one. What was left out is +// and two reads of the whole list by the owner that same day took it to +// twenty-three. `alt+3`, `alt+1`–`alt+7`, `/search` and `/subharness` came off +// as rows the foot or the tab bar already teaches; the two lines about a +// running answer became one; `/ask`'s came off ahead of the door it taught; +// `/folder`'s came off because it was not true and /attach's line now covers +// both kinds; and the second /attach row was one row too many about one +// command. What was left out is // what the foot already names — `alt+p`, `alt+e`, `alt+a`, `alt+k`, `/` — and // the second spelling of anything already here. `/ shows every command` was a // row until both feet started saying `/ commands` outright (footswap.go). @@ -374,12 +373,11 @@ var notices = []notice{ retire: eventStandingOpened, }, // ── starting work ─────────────────────────────────────────────────────── - { - id: "ask-on-home", slot: slotHint, - armed: askable, - text: "/ask answers right here without opening a conversation", - retire: eventAsked, - }, + // + // `/ask answers right here without opening a conversation` stood here + // until 2026-09-22 and came off ahead of the door it taught: /ask is on + // its way out, and a tip is a thing to teach somebody who will still have + // it tomorrow. The command itself is untouched. { id: "task-in-chat", slot: slotHint, armed: spoken, @@ -414,33 +412,25 @@ var notices = []notice{ { id: "attach-a-file", slot: slotHint, armed: ready, - text: "/attach sends a file along with your message", + text: "/attach sends a file or folder with your message", retire: eventAttached, }, - { - id: "pick-a-folder", slot: slotHint, - armed: ready, - text: "/folder picks the folder codeaf works in", - retire: eventFolderPicked, - }, + // `/folder picks the folder codeaf works in` stood here until 2026-09-22 + // and was NOT TRUE: /folder never moves the directory codeaf is standing + // in — that is fixed for the life of a conversation — it registers a + // directory the conversation is ABOUT (folderplace.go's [app.referPlace]), + // which is what /attach does with a folder after it, through the very same + // seam. So the row came off and /attach's says "a file or folder". The + // command, and `/place` and `/dir` with it, is untouched. { // ON HOME ALONE, because /project is home's alone (projectcmd.go). A // conversation's box would be reading it over a command that answers // there by pointing back at home. id: "pick-a-project", slot: slotHint, armed: onHome, - text: "/project sets the folder the next conversation opens in", + text: "/project sets the project folder for the next conversation", retire: eventProjectSet, }, - { - // THE ID OUTLIVED ITS OWN WORDS. The line named a picture until - // 2026-09-22 and names the browser now; the id may not change with it - // ([notice.id] says why), so it reads as a misnomer on purpose. - id: "attach-a-picture", slot: slotHint, - armed: ready, - text: "/attach lets you browse anywhere for files", - retire: eventAttached, - }, { id: "export-the-conversation", slot: slotHint, armed: func(a *app) bool { return a.turn >= 2 }, @@ -475,14 +465,14 @@ var notices = []notice{ // not been told the other half ([notice.id]). id: "steer-and-queue", slot: slotHint, armed: spoken, - text: "using enter stops and steers conversations, use ctrl+q to queue", + text: "using enter steers conversations · use ctrl+q to queue", retire: eventQueued, }, // ── moving around ─────────────────────────────────────────────────────── { id: "new-chat", slot: slotHint, armed: ready, - text: "ctrl+t starts a fresh chat in this folder", + text: "ctrl+t starts a fresh chat in this project", retire: eventChatStarted, }, // ── memory, accounts and the rest ─────────────────────────────────────── From 8be1fc0d838e77a6e6b9fa95649dab3976748c2b Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:35:09 -0400 Subject: [PATCH 22/39] chat: a conversation's tip goes back to the keys row, on its old mechanism MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner's ruling: put the conversation's hints back where they were and trigger them the way they were triggered, keeping the new content. So the two boxes diverge again, deliberately — a conversation is a screen you sit in and home is a screen you pass through, and the tip worth saying differs. A CONVERSATION, as it was before this wave: - the tip is the LOWEST RUNG OF THE KEYS ROW at the foot, under every state's own keys, taking the slot from the rest state - it is decided by the events that prove what is happening, ranked by the table's own order — the first eligible row wins, and the table's order is what the old per-row `priority` column said in numbers - the slot changes hands slowly: a different line may take it only once `noticeGap` turns have passed, so three tips arming in three turns are read one at a time - a showing is one SESSION, counted when the slot takes the tip - no clock, no quiet minute, no rotation, no cross HOME keeps everything this wave gave it: the row over the rule, the bulb and the cross, the rotation on every visit and every two minutes, and a showing that is twenty seconds of standing where it could be seen. So `pick` forks into `rank` (the conversation) and `rotate` (home), and `take` counts on arrival for one and on departure for the other. Gone with the conversation's clock: hintTickMsg, noticeArmIdle, noticeIdleBeat, noticeTouched, chatHintIdle, the board's touched/due/idleArmed/idleGen, chromeTip and tipClosePress. One list still feeds both, so a row armed only on home — /project — is simply never a candidate in a conversation, and using a gesture on either box retires its tip on both. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- internal/manual/chat/hints-and-tips.md | 82 +++---- internal/manual/chat/home.md | 10 +- internal/manual/chat/places.md | 7 +- internal/manual/chat/screen.md | 6 +- internal/tui3/app.go | 33 +-- internal/tui3/chattip_test.go | 165 ++++---------- internal/tui3/hometip_test.go | 78 ++++--- internal/tui3/notice.go | 295 ++++++++++++------------- internal/tui3/notice_test.go | 233 +++++++++++++------ internal/tui3/projectseam.go | 18 +- internal/tui3/render.go | 17 +- internal/tui3/view.go | 25 +-- 12 files changed, 463 insertions(+), 506 deletions(-) diff --git a/internal/manual/chat/hints-and-tips.md b/internal/manual/chat/hints-and-tips.md index 953b638f49..d7a4cfe6bf 100644 --- a/internal/manual/chat/hints-and-tips.md +++ b/internal/manual/chat/hints-and-tips.md @@ -1,35 +1,33 @@ # Hints and tips -## What was that tip above the message box — the one-line hint over the rule, the sentence with a bulb - -The dim sentence directly above the rule over your message box, led by a bulb and closed -by a small cross — `💡 ctrl+. sees every task this project has run ✕` — is a **tip**: one -line naming a key or a command you have not used yet, and what it does. It reads the way -every hint on this surface does: the key or the command first, then what it does. Home has -the same row over its own box, and the two rows draw from **one list** of twenty-three tips -(below). - -**In a conversation the row appears only once you have been quiet for a minute** — no key -pressed and no answer landing for sixty seconds — so it never talks over you while you type -or read what just arrived. The moment you press a key it goes away, and the minute starts -again. Left alone, the row moves on to the next tip every two minutes. On home the row is -there from the first minute, moves on every time you come to home and every two minutes at -rest, and goes blank while the box is being typed into or a list is up. - -**The small cross after the tip means ENOUGH OF THESE FOR NOW**: click it and the row goes -blank and stays blank — no second sentence takes its place on the screen you are still -looking at. **The cross is lifted when the row leaves the frame, and by nothing else.** On -home that means leaving home and coming back; in a conversation it means the next key -taking the row away and the next quiet minute bringing it back. Neither the two-minute -beat nor anything else happening on the screen brings it back sooner. The tip you put away -is **not** spent: it keeps its whole allowance, nothing is written down, and the row that -comes back is a different one, with the one you dismissed taking its turn again later. -Until 2026-09-22 the cross counted the tip as shown, so the same sentence was back with -one of its six showings gone. A tip never takes a row of its own: it stands on the blank row that separates the conversation (or home's list) from the -rule, and never blocks a keystroke. The keys row at the very foot — `alt+e effort · alt+a -approvals · / commands` — is not a tip and never changes; until 2026-09-22 the tip stood -there in a conversation, and it moved up to the row over the rule so both boxes say their -tips the same way. +## What was that tip at the bottom of the screen — the one-line hint, the sentence under the message box + +The dim sentence at the very bottom of a conversation, where the keys usually are — +`ctrl+. sees every task this project has run` — is a **tip**: one line naming a key or a +command you have not used yet, and what it does. It reads the way every hint on this +surface does: the key or the command first, then what it does. + +**In a conversation the tip is the keys row's lowest rung.** It takes that row from the +rest state — the line a newcomer reads when nothing is happening — and every state with +keys of its own outranks it: a running turn, a list, a panel, a room, and a box with so +much as one letter in it. Empty the box and it is back. There is no clock over it and no +cross on it: the event that makes a tip true puts it there, and it stays until something +truer takes the row or you use what it teaches. + +**Home says its tips differently, and the two are not the same row.** Home's is the dim +line **directly above the rule** over its box, right-aligned, led by a bulb and closed by a +small cross. It rotates on every visit and every two minutes at rest, and the cross blanks +it until you leave home and come back. A conversation is a screen you sit in and home is a +screen you pass through, so the tip worth saying differs: a conversation gets the most +urgent thing that is true right now, and home gets everything in turn. + +Both rows draw from **one list** of twenty-three tips (below), and using a gesture on +either retires it on both. + +**A conversation's tip has changed places twice.** It was the keys row's lowest rung until +2026-09-22, moved up to a row of its own over the rule that day — with a quiet minute +before it appeared, a two-minute rotation and a cross — and moved back to the keys row the +same day, which is where it is now. On a Mac the row says `opt` where the table below says `alt`, exactly as the keys row does. @@ -57,16 +55,20 @@ project has run` never comes back; run `/compact` once and the compact tip is re retired from either box is retired from both: opening the model list on home retires `/model lists every model` in every conversation as well. -A tip you never act on is not shown forever either. **A showing is a tip that stood for -twenty seconds or more on a row you could see** — home's row while home was in front, a -conversation's row after its quiet minute — and once a tip has been shown six times it is -taken as read and retires by itself. Passing through home for a second or two is not a -showing, however many times you do it, and a row deciding while nobody could see it — -home's while you are in a conversation, a conversation's before its quiet minute — is not -one either. Until 2026-09-22 every visible change of hands counted, so an afternoon of -stepping through home could spend the whole table in flashes nobody read; the first -launch of a build with the twenty-second rule gives back, once, every tip that rule -spent, and leaves retired every tip you retired by using it. +A tip you never act on is not shown forever either. Once a tip has been **shown six times** +it is taken as read and retires by itself — and the two rows count a showing differently, +because they behave differently. + +- **In a conversation, a showing is one session.** However many times the tip comes and + goes on the keys row while you work, that is one showing, counted the first time the row + takes it. Six sessions of never acting on it and it is done. +- **On home, a showing is a tip that stood twenty seconds or more on a row you could see.** + Passing through home for a second or two is not a showing, however many times you do it, + and a row deciding while home is not in front is not one either. Until 2026-09-22 every + visible change of hands on home counted, so an afternoon of stepping through could spend + the whole table in flashes nobody read; the first launch of a build with the + twenty-second rule gives back, once, every tip that rule spent, and leaves retired every + tip you retired by using it. This is remembered per profile, in a small file called `notices.json` beside `config.json` in your codeaf profile directory. Retiring is permanent: turning hints off and on does not diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index ef15d67cc8..e736c9343d 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -1394,11 +1394,11 @@ home and every two minutes at rest, and each tip goes away for good the first ti what it names. It sits at the right, led by a bulb and closed by a small cross: clicking it means ENOUGH FOR NOW, and the row stays blank until you leave home and come back, when a different tip is there. The tip you put away keeps its whole allowance and comes round -again. A conversation has the same row over its own box, from -the same one list of tips, drawn once you have been quiet there for a minute. The keys row -at the very foot is not a tip and never changes. The whole list, what makes each one appear -and disappear, and the **disable hints** row on the Workspace tab that turns them off, are on -the *hints and tips* page. +again. **A conversation says its tips differently**: there the tip is the lowest rung of +the keys row at the very foot, with no bulb, no cross and no clock — it is simply there +whenever nothing else is happening. Both rows draw from the same one list. The whole list, +what makes each one appear and disappear, and the **disable hints** row on the Workspace +tab that turns them off, are on the *hints and tips* page. ## Typing @ on home — does the @ file list work on home, complete a path into home's box diff --git a/internal/manual/chat/places.md b/internal/manual/chat/places.md index 9d2edab650..c89326325a 100644 --- a/internal/manual/chat/places.md +++ b/internal/manual/chat/places.md @@ -210,9 +210,10 @@ on home and in conversations. On both boxes `project: <path>` is at the right en **keys row under the box** (it left the rule on 2026-09-22): home's names where the next conversation opens, a conversation's names its own workspace. The keys keep their room: a long path truncates on the right, and the field disappears if there is less than a word -of room. The dim line above the rule, when there is one, is a tip (see *hints and tips*) -— on home from the first minute, in a conversation once you have been quiet for a -minute. The bottom row names the available project, effort +of room. **On home** the dim line above the rule, when there is one, is a tip (see *hints +and tips*), there from the first minute. **In a conversation** the tip is not that row at +all: it is the lowest rung of the keys row itself, taking the slot from the rest state +whenever nothing else is happening. The bottom row names the available project, effort and approval controls; the cells can also be pressed: | cell | chord | or | diff --git a/internal/manual/chat/screen.md b/internal/manual/chat/screen.md index 8e60b947b1..5dbb28facf 100644 --- a/internal/manual/chat/screen.md +++ b/internal/manual/chat/screen.md @@ -767,8 +767,10 @@ long title could never push the numbers off the frame, and the name moved off ag **The keys row under the box** — the last row of the frame — is the hint slot. Until 2026-09-17 it was the right end of the rule above the box; the numbers took that end and the keys got a row of their own. Since 2026-09-22 it carries `project: <path>` at its right -end and never an earned tip — the tips stand on the row above the rule, after a quiet -minute (see *hints and tips*). It names the keys that work right now when a state has +end, and its LOWEST RUNG is the earned tip (see *hints and tips*): a conversation's tip +lives on this row, under every state's own keys, and takes the slot from the rest state +whenever nothing is happening. Home's tip is the row above the rule instead. It names the +keys that work right now when a state has keys of its own — for example `y allow · n deny · a always` while a question is up, `ctrl+c interrupt` while a turn is running, `enter steers it in · ctrl+shift+enter stops and sends · ctrl+c interrupt` while a turn is diff --git a/internal/tui3/app.go b/internal/tui3/app.go index 3d452d248b..15b9f02c8b 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -3202,12 +3202,6 @@ func (a *app) Init() tea.Cmd { standing = append(standing, a.wake()) } } - // THE CONVERSATION'S TIP CLOCK IS STARTED HERE, once, and keeps itself - // going (notice.go's THE CONVERSATION'S CLOCK). It is the one long-period - // clock this surface runs — a minute at a time, never a frame — and it - // stands in the same flat batch as the rest, because a test reads that - // batch one level deep for the terminal's colour question (adaptive_test.go). - standing = append(standing, a.noticeArmIdle()) return tea.Batch(standing...) } @@ -3344,12 +3338,9 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyPressMsg: a.sawAPerson() - // AND THE CONVERSATION'S TIP CLOCK IS STAMPED HERE TOO, on the same - // argument: a key is the proof somebody is doing something, and a tip - // over a conversation waits for a minute of nobody doing anything - // (notice.go's [app.noticeTouched]). - a.noticeTouched() - // AND THE HAND IS STAMPED HERE, for the same reason the line above is: + // AND THE HAND IS STAMPED HERE, because this is the only line every + // keypress passes through, and what the question block needs to know + // is whether somebody is at the keyboard at all: // this is the only line every keypress passes through, and what the // question block needs to know is whether somebody is at the keyboard // at all (question.go's [app.questionQuieted]). @@ -3987,12 +3978,6 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { if a.jumpPress(msg.Mouse().X, msg.Mouse().Y) { return a, nil } - // AND THE CROSS ON THE TIP ROW RIDES THE SAME GAP, when the chip does - // not: a press on it puts the tip away (projectseam.go's - // [app.tipClosePress]). - if a.tipClosePress(msg.Mouse().X, msg.Mouse().Y) { - return a, nil - } // AND THE DOOR HOME IS THE THIRD, in the hint slot at the right end // of the legend. Column-aware for the same reason again: the rest of // that rule is a rule, and pressing a rule means nothing (home.go). @@ -4508,12 +4493,6 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { // without touching the filter somebody is typing (folderplace.go). return a, a.tookFolderStore(msg) - case hintTickMsg: - // THE CONVERSATION'S TIP CLOCK, landing: a minute of nobody doing - // anything shows the row's tip, and every two minutes after moves it on - // (notice.go's THE CONVERSATION'S CLOCK). - return a, a.noticeIdleBeat(msg.gen) - case homeTickMsg: // HOME IS LIVE, and this is the whole of how: read the folders again, // then ask for one more beat. It rides its own clock rather than the @@ -5740,11 +5719,7 @@ func (a *app) settle() tea.Cmd { a.notices.enabled = config.HintsAt(a.profileDir) // A turn ending is the moment most hints become true — the answer was long, // the window is half full, the money is real — so it is the event they are - // decided on (notice.go). - // AND A TURN ENDING IS THE OTHER THING THAT STAMPS THE TIP CLOCK: the - // answer that just landed is what the person is reading now, and the - // row over the box waits its minute from here ([app.noticeTouched]). - a.noticeTouched() + // decided on, and it is the turn [noticeGap] is counted in (notice.go). a.noticeEvent(eventTurnEnded) a.follow() a.touch() diff --git a/internal/tui3/chattip_test.go b/internal/tui3/chattip_test.go index 0f6f94f2ce..e33868fdec 100644 --- a/internal/tui3/chattip_test.go +++ b/internal/tui3/chattip_test.go @@ -13,15 +13,17 @@ import ( "github.com/Agent-Field/codeaf/internal/tui2/tokens" ) -// ── THE CONVERSATION'S TIP ROW, ITS KEYS ROW'S PROJECT, AND TWO DOORS ──────── +// ── THE CONVERSATION'S TIP, ITS KEYS ROW'S PROJECT, AND TWO DOORS ─────────── // -// Since 2026-09-22 a conversation says its tips the way home does — on the row -// over the rule, right-aligned, with a bulb and a cross — and only once the -// person has been quiet for a minute (notice.go's THE CONVERSATION'S CLOCK). -// The project came down off the seam to the right end of the keys row, as it -// did on home. And two of the owner's bug reports from the same day: enter on -// `/attach` in the list opens the browser at once, and the search place finds -// conversations by name when memory is off. +// A conversation's tip is the LOWEST RUNG OF THE KEYS ROW at the foot, decided +// by the events that prove what is happening and drawn whenever the frame is +// quiet. It had a row of its own over the rule for one build on 2026-09-22 — +// with a quiet minute before it appeared, a two-minute rotation and a cross — +// and the owner put it back here. Home's row keeps that newer shape, and +// hometip_test.go holds it to that. The project came down off the seam to the +// right end of the keys row, as it did on home. And two of the owner's bug +// reports from the same day: enter on `/attach` in the list opens the browser +// at once, and the search place finds conversations by name when memory is off. // chatTipLab is a conversation over a clock the test turns by hand. func chatTipLab(t *testing.T) (*app, func(time.Duration)) { @@ -43,130 +45,63 @@ func tipRowOf(a *app, tip string) (int, []string) { return -1, rows } -// A conversation's row says nothing until the person has been quiet for -// [chatHintIdle]; the one clock measures from the last key; the row then draws -// over the rule with the bulb and the cross, the keys row does not carry it, -// the cross puts it away, the next beat moves the row on, and a key hides it -// again until the next quiet minute. -func TestAConversationSaysATipOnlyAfterAQuietMinute(t *testing.T) { - a, advance := chatTipLab(t) +// THE TIP IS THE KEYS ROW'S LOWEST RUNG, on no clock at all: the event that +// arms it puts it there, and it is drawn from that moment on while the frame is +// quiet. A key in the box takes the row back because the row belongs to the +// sentence being written, and emptying the box gives it back at once — no +// minute, no beat, no cross. +func TestAConversationSaysItsTipOnTheKeysRow(t *testing.T) { + a, _ := chatTipLab(t) b := &a.notices - if a.noticeArmIdle() == nil { - t.Fatal("the surface coming up did not start the clock") - } - if a.noticeArmIdle() != nil { - t.Fatal("a second start armed a second clock") - } startTask(t, a) if b.current[slotHint] != "task-page-after-first-task" { t.Fatalf("a task starting armed %q", b.current[slotHint]) } - if got := a.noticeHint(); got != "" { - t.Fatalf("the tip drew before a quiet minute: %q", got) - } - // A BEAT BEFORE THE MINUTE GOES BACK TO SLEEP for what is left. - advance(30 * time.Second) - if cmd := a.noticeIdleBeat(b.idleGen); cmd == nil || b.due { - t.Fatal("a beat inside the minute did not go back to sleep") - } - // A KEY STAMPS THE CLOCK AGAIN, so the minute is measured from it. - drive(t, a, key("x"), key("backspace")) - advance(45 * time.Second) - if cmd := a.noticeIdleBeat(b.idleGen); cmd == nil || b.due { - t.Fatal("the beat did not measure the minute from the last key") - } - advance(chatHintIdle) - if cmd := a.noticeIdleBeat(b.idleGen); cmd == nil || !b.due { - t.Fatal("a quiet minute did not make the tip due") - } if got := a.noticeHint(); got != taskPageTip { - t.Fatalf("after a quiet minute the row reads %q, want the tip", got) + t.Fatalf("the tip is not up the moment it arms: %q", got) + } + + // ON THE FRAME: the foot, under the box, and NOT a row of its own over the + // rule. + if got := plain(a.footHint(a.width)); !strings.Contains(got, taskPageTip) { + t.Fatalf("the keys row does not carry the tip: %q", got) } - // ON THE FRAME: the row directly over the rule, right-aligned, bulb and cross. y, rows := tipRowOf(a, taskPageTip) if y < 0 { t.Fatalf("the tip is not on the frame:\n%s", strings.Join(rows, "\n")) } - row := strings.TrimRight(rows[y], " ") - cross := a.pal.glyph(tokens.GFailed) - if !strings.HasSuffix(row, homeTipLead+homeTipGap+taskPageTip+homeTipGap+cross) { - t.Fatalf("the tip row does not end with the bulb, the tip and the cross: %q", row) - } - if got := ansi.StringWidth(row); got != a.width-1 { - t.Fatalf("the tip row measures %d cells on a %d-cell frame, want %d", got, a.width, a.width-1) - } - if y+1 >= len(rows) || !strings.HasPrefix(rows[y+1], "─") { - t.Fatalf("the rule is not the row under the tip:\n%s", strings.Join(rows, "\n")) - } - if got := a.footHint(a.width); strings.Contains(got, taskPageTip) { - t.Fatalf("the keys row still carries the tip: %q", got) - } - // THE CROSS. A press on it puts the tip away; a press beside it does not. - if !a.tipCloseSpan.pressable() { - t.Fatal("the draw recorded no columns for the cross") - } - if a.tipClosePress(a.tipCloseSpan.from-4, y) { - t.Fatal("a press on the tip's words was taken as the cross") + if y+1 < len(rows) && strings.HasPrefix(rows[y+1], "─") { + t.Fatalf("the tip is sitting over the rule again:\n%s", strings.Join(rows, "\n")) } - if !a.tipClosePress(a.tipCloseSpan.from, y) { - t.Fatal("a press on the cross was not taken") - } - if got := a.noticeHint(); got != "" { - t.Fatalf("the cross did not put the tip away: %q", got) - } - if strings.Contains(plain(frame(a)), taskPageTip) { - t.Fatal("the tip is still drawn after its cross was pressed") - } - if b.retired("task-page-after-first-task") { - t.Fatal("putting a tip away retired it") - } - // AND THE BEAT ALONE DOES NOT BRING IT BACK (the owner's ruling, - // 2026-09-22): a cross holds the row until the row leaves the frame, and - // the two-minute beat is what used to answer it with another sentence on - // the screen somebody was still sitting in front of. - advance(hintEvery) - if cmd := a.noticeIdleBeat(b.idleGen); cmd == nil { - t.Fatal("the beat after a quiet minute did not re-arm") + // AND IT WEARS NO BULB AND NO CROSS. Those belong to home's row. + cross := a.pal.glyph(tokens.GFailed) + if row := rows[y]; strings.Contains(row, homeTipLead) || strings.HasSuffix(strings.TrimRight(row, " "), cross) { + t.Fatalf("the conversation's tip wears home's bulb or cross: %q", row) } + + // A LETTER IN THE BOX TAKES THE ROW; emptying it gives the row back. + drive(t, a, key("x")) if got := a.noticeHint(); got != "" { - t.Fatalf("the beat brought the row back under a cross: %q", got) - } - // A KEY TAKES THE ROW, and the next quiet minute gives it back — the ring - // has one eligible tip here, so it is the same one. - drive(t, a, key("y"), key("backspace")) - if b.due || a.noticeHint() != "" { - t.Fatalf("a key did not stand the tip down: due=%v hint=%q", b.due, a.noticeHint()) - } - advance(chatHintIdle) - if cmd := a.noticeIdleBeat(b.idleGen); cmd == nil { - t.Fatal("the beat after the fresh quiet minute did not re-arm") + t.Fatalf("the tip drew over a box with a letter in it: %q", got) } + drive(t, a, key("backspace")) if got := a.noticeHint(); got != taskPageTip { - t.Fatalf("a key and a fresh quiet minute did not bring the row back: %q", got) + t.Fatalf("emptying the box did not give the row back: %q", got) } - // And a key stands it down again, which is where the rest of this test - // picks up. - drive(t, a, key("y"), key("backspace")) - if b.due || a.noticeHint() != "" { - t.Fatalf("a key did not stand the tip down: due=%v hint=%q", b.due, a.noticeHint()) + + // A RUNNING TURN TAKES IT TOO, and every state with keys of its own. + a.state = stateWorking + if got := a.noticeHint(); got != "" { + t.Fatalf("the tip drew over a running turn: %q", got) } - // A PLACE IN FRONT SLEEPS THE MINUTE AGAIN, showing nothing, and a beat - // from an older arming is dropped. + a.state = stateIdle a.showPage(pageSpend) - advance(2 * chatHintIdle) - if cmd := a.noticeIdleBeat(b.idleGen); cmd == nil || b.due { - t.Fatal("a beat over a place did not sleep the minute again") - } - if cmd := a.noticeIdleBeat(b.idleGen - 1); cmd != nil { - t.Fatal("a beat from an older arming was not dropped") + if got := a.noticeHint(); got != "" { + t.Fatalf("the tip drew under a place: %q", got) } a.leavePlace() - if a.showing() != nil { - t.Fatalf("the conversation did not come back; %v is showing", a.showing().id()) - } - advance(2 * chatHintIdle) - if cmd := a.noticeIdleBeat(b.idleGen); cmd == nil || !b.due { - t.Fatal("the conversation coming back and going quiet did not bring the tip") + if got := a.noticeHint(); got != taskPageTip { + t.Fatalf("leaving the place did not give the row back: %q", got) } } @@ -174,7 +109,6 @@ func TestAConversationSaysATipOnlyAfterAQuietMinute(t *testing.T) { func TestDisableHintsSilencesTheConversationRow(t *testing.T) { a, _ := chatTipLab(t) startTask(t, a) - a.notices.due = true if a.noticeHint() == "" { t.Fatal("the tip is not up before the toggle") } @@ -182,11 +116,8 @@ func TestDisableHintsSilencesTheConversationRow(t *testing.T) { if got := a.noticeHint(); got != "" { t.Fatalf("a silenced profile still says %q in a conversation", got) } - // The clock keeps ticking over a silenced profile, showing nothing, so - // turning hints back on needs no restart. - a.noticeArmIdle() - if cmd := a.noticeIdleBeat(a.notices.idleGen); cmd == nil { - t.Fatal("a silenced profile stopped the tip clock") + if got := plain(a.footHint(a.width)); strings.Contains(got, taskPageTip) { + t.Fatalf("a silenced profile still draws the tip on the keys row: %q", got) } } diff --git a/internal/tui3/hometip_test.go b/internal/tui3/hometip_test.go index e8b679bebe..0176e07faa 100644 --- a/internal/tui3/hometip_test.go +++ b/internal/tui3/hometip_test.go @@ -170,11 +170,12 @@ func TestATipSpentOnHomeIsSpentEverywhere(t *testing.T) { } } -// Every turn of a row's rotation that stood long enough to be read is a -// showing, on either box, and a tip that has come round [noticeShownDefault] -// times that way is taken as read. +// Every turn of HOME's rotation that stood long enough to be read is a showing, +// and a tip that has come round [noticeShownDefault] times that way is taken as +// read. The conversation's row counts its showings by the session instead +// (notice_test.go), which is why only home's slot is walked here. func TestEveryTurnOfTheRotationThatStoodIsAShowing(t *testing.T) { - for _, slot := range []noticeSlot{slotHome, slotHint} { + for _, slot := range []noticeSlot{slotHome} { b := bareNoticeBoard() now := time.Date(2026, 9, 22, 9, 0, 0, 0, time.UTC) limit := func(string) int { return noticeShownDefault } @@ -182,11 +183,11 @@ func TestEveryTurnOfTheRotationThatStoodIsAShowing(t *testing.T) { turns := map[string]int{} for i := 0; i < 2*noticeShownDefault; i++ { b.advance[slot] = true - id := b.pick(slot, cands) + id := b.pick(slot, cands, 0) if id == "" { t.Fatalf("turn %d put nothing on the row", i) } - b.take(slot, id, true, now, limit) + b.take(slot, id, true, now, limit, 0) turns[id]++ now = now.Add(noticeReadTime) } @@ -198,7 +199,7 @@ func TestEveryTurnOfTheRotationThatStoodIsAShowing(t *testing.T) { t.Fatalf("after %d turns each the tips are not retired: %+v", noticeShownDefault, b.ledger) } b.advance[slot] = true - if got := b.pick(slot, cands); got != "" { + if got := b.pick(slot, cands, 0); got != "" { t.Fatalf("a retired tip came back: %q", got) } } @@ -211,24 +212,24 @@ func TestARowHoldsBetweenVisitsAndYieldsWhenSpent(t *testing.T) { b := bareNoticeBoard() cands := []noticeCandidate{{id: "a", armed: true}, {id: "b", armed: true}, {id: "c", armed: true}} b.advance[slotHome] = true - if got := b.pick(slotHome, cands); got != "a" { + if got := b.pick(slotHome, cands, 0); got != "a" { t.Fatalf("the ring did not start at the top: %q", got) } - b.take(slotHome, "a", true, time.Now(), func(string) int { return noticeShownDefault }) + b.take(slotHome, "a", true, time.Now(), func(string) int { return noticeShownDefault }, 0) // An event with nothing advancing keeps the one standing. - if got := b.pick(slotHome, cands); got != "a" { + if got := b.pick(slotHome, cands, 0); got != "a" { t.Fatalf("an event moved the row without a visit, to %q", got) } // The one standing retiring hands the row to the next in the ring. b.retire("a") - if got := b.pick(slotHome, cands); got != "b" { + if got := b.pick(slotHome, cands, 0); got != "b" { t.Fatalf("a spent tip did not yield to the next: %q", got) } // And with nothing eligible the row is empty rather than stale. for _, c := range cands { b.retire(c.id) } - if got := b.pick(slotHome, cands); got != "" { + if got := b.pick(slotHome, cands, 0); got != "" { t.Fatalf("an empty ring still says %q", got) } } @@ -345,35 +346,23 @@ func TestTheCrossBlanksHomesRowAndSpendsNothingOfTheTipItPutAway(t *testing.T) { } } -// AND THE SAME LAW IN A CONVERSATION, where the row going out of view is a key -// rather than a door: the cross blanks it, the quiet minutes that follow do -// not bring it back, and a key and a fresh quiet minute do. -func TestTheCrossHoldsTheConversationsRowUntilAKeyAndAFreshQuietMinute(t *testing.T) { +// THE CROSS IS HOME'S ALONE. A conversation says its tip on the keys row +// (chattip_test.go), and a keys row has never had one: there is no span for a +// press to land in, and nothing on that row is a door. +func TestAConversationsTipRowCarriesNoCross(t *testing.T) { a, _ := sheetApp(t) - a.turn = 1 - a.noticeEvent(eventTurnEnded) - advance := quietMinute(a) + startTask(t, a) if a.noticeHint() == "" { - t.Fatal("the quiet minute drew no tip") - } - - a.noticeDismiss(slotHint) - if got := a.noticeHint(); got != "" { - t.Fatalf("the cross answered with another tip: %q", got) + t.Fatal("the conversation says no tip to begin with") } - // The beat that turns the ring every two minutes does not lift it. - advance(hintEvery * 2) - a.noticeIdleBeat(a.notices.idleGen) - if got := a.noticeHint(); got != "" { - t.Fatalf("the two-minute beat brought the row back: %q", got) + frame(a) + if a.tipCloseSpan.pressable() { + t.Fatalf("a conversation drew a cross at columns %+v", a.tipCloseSpan) } - - // A key takes the row, and the next quiet minute gives it back. - a.noticeTouched() - advance(chatHintIdle) - a.noticeIdleBeat(a.notices.idleGen) - if a.noticeHint() == "" { - t.Fatal("a key and a fresh quiet minute did not bring the row back") + // And the tip is on the keys row rather than on a row of its own with a + // cross at the end of it. + if got := plain(a.footHint(a.width)); !strings.Contains(got, taskPageTip) { + t.Fatalf("the conversation's tip is not on the keys row: %q", got) } } @@ -468,13 +457,22 @@ func TestATipBehindAHiddenRowStandsForNothing(t *testing.T) { t.Fatal("the cross did not blank the row") } - // AN HOUR OF EVENTS OVER A BLANK ROW. Each one re-decides the slot. + // AN HOUR OF EVENTS OVER A BLANK ROW. Each one re-decides the slot. The + // conversation's slot takes the same tip and counts it ONCE, which is its + // own rule (notice.go's [noticeBoard.take]); what is under test is that + // home's blank row adds nothing on top of that, ever. + now = now.Add(noticeReadTime * 2) + a.noticeEvent(eventTurnEnded) + settled := a.notices.ledger.shown(last) for i := 0; i < noticeShownDefault*3; i++ { now = now.Add(noticeReadTime * 2) a.noticeEvent(eventTurnEnded) } - if got := a.notices.ledger.shown(last); got != 0 { - t.Fatalf("a tip behind a blank row was shown %d times", got) + if got := a.notices.ledger.shown(last); got != settled { + t.Fatalf("a tip behind a blank row climbed from %d to %d showings", settled, got) + } + if !a.notices.since[slotHome].IsZero() { + t.Fatal("a blank home row started a standing") } if a.notices.retired(last) { t.Fatal("a tip behind a blank row retired itself") diff --git a/internal/tui3/notice.go b/internal/tui3/notice.go index b1e2947fb7..ae3265def1 100644 --- a/internal/tui3/notice.go +++ b/internal/tui3/notice.go @@ -9,8 +9,6 @@ import ( "strings" "time" - tea "charm.land/bubbletea/v2" - "github.com/Agent-Field/codeaf/internal/buildinfo" ) @@ -255,20 +253,19 @@ const noticeShownDefault = 6 // visible row was on a screen somebody was looking at. const noticeReadTime = 20 * time.Second +// noticeGap is the fewest turns between one tip standing down on the +// CONVERSATION's row and a different one taking it. It is what keeps a busy +// first session from reading as a slideshow: three tips arming in three +// consecutive turns are shown one at a time, each with room to be read. Home's +// row is not on turns at all — it is on visits and a clock ([hintEvery]). +const noticeGap = 2 + // hintEvery is how long a tip stands on a row before the next one takes it, // while the row is left at rest: home at rest, or a conversation the person // has gone quiet in. Two minutes is long enough to be read and short enough // that a window left open over lunch has said a few things. const hintEvery = 2 * time.Minute -// chatHintIdle is how long a conversation has to have been left alone — -// no key pressed, no turn ending — before its row says a tip at all. A -// conversation is where the work is, and a sentence appearing over the box -// while somebody is typing or reading an answer that has just landed is the -// surface talking over them; a minute of nothing is the moment they are -// looking around. -const chatHintIdle = time.Minute - // The arming thresholds, each named once so the manual page and the table // cannot drift apart about when a hint appears. const ( @@ -610,42 +607,40 @@ type noticeBoard struct { // armed is, per slot, whether each row was armed at that slot's last // decision — what makes a row FRESH at the next one ([noticeCandidate.fresh]). armed [noticeSlots]map[string]bool - // advance asks the next decision about a slot to move on to the next + // ── HOME'S HALF ──────────────────────────────────────────────────────── + // + // advance asks the next decision about home's row to move on to the next // eligible tip rather than keep the one standing. It is raised by // [app.noticeRotate] — a visit to home, a beat at rest — and spent by the - // pick that honours it, so an event between two rotations leaves a row + // pick that honours it, so an event between two rotations leaves the row // alone unless the tip on it has just retired or a fresh one has arrived. advance [noticeSlots]bool // at is when each slot last changed hands, or zero when it never has; - // [hintEvery] is measured from it by the beats. + // [hintEvery] is measured from it by home's beat. at [noticeSlots]time.Time - // since is when the tip standing in each slot became VISIBLE — the row in + // since is when the tip standing in home's row became VISIBLE — home in // front and the tip on it — or zero while it cannot be seen. A showing is // counted from it when the tip leaves or the row goes out of sight // ([noticeBoard.settle]), and only if it stood [noticeReadTime]. since [noticeSlots]time.Time - // hidden is the cross on a row having been pressed: the row draws nothing - // at all until THE ROW ITSELF GOES OUT OF VIEW, which is the only thing - // that lifts it — home closing ([app.dropHome]) for home's row, a key - // taking the conversation's row back and the next quiet minute returning - // it ([app.noticeIdleBeat]) for that one. Deciding the slot again does - // not lift it, and neither does the two-minute beat: a cross answered - // with another sentence on the same screen is the surface talking over - // somebody who asked it to stop (the owner's ruling, 2026-09-22). It is - // this session's and never the ledger's — putting a tip away is not - // using it. + // hidden is the cross on home's row having been pressed: the row draws + // nothing at all until HOME ITSELF GOES OUT OF VIEW ([app.dropHome]), + // which is the only thing that lifts it. Deciding the slot again does not + // lift it, and neither does the two-minute beat: a cross answered with + // another sentence on the same screen is the surface talking over somebody + // who asked it to stop (the owner's ruling, 2026-09-22). It is this + // session's and never the ledger's — putting a tip away is not using it. hidden [noticeSlots]bool - // touched is the last proof the person was doing something in a - // conversation — a key pressed, a turn ending — and due is whether they - // have since been quiet for [chatHintIdle], which is what lets the - // conversation's row draw at all ([app.noticeHint]). - touched time.Time - due bool - // idleArmed and idleGen are the conversation's one clock: whether a beat is - // pending, and which arming it belongs to, so a beat from a clock that has - // since been re-armed is dropped ([app.noticeIdleBeat]). - idleArmed bool - idleGen int + + // ── THE CONVERSATION'S HALF ──────────────────────────────────────────── + // + // shown is every notice counted as shown this session on the conversation's + // row, so an hour in the slot is one showing and not one per event. Home's + // row does not use it: a showing there is a standing, measured in seconds. + shown map[string]bool + // lastHintTurn is the turn the conversation's slot last changed hands on, + // or -1 when it never has; [noticeGap] is measured from it. + lastHintTurn int } // bareNoticeBoard is a board with nothing behind it: no ledger on disk, no @@ -654,21 +649,25 @@ type noticeBoard struct { // nothing — which is why it is reachable from a frame and the loader is not. func bareNoticeBoard() noticeBoard { return noticeBoard{ - enabled: true, - seen: map[string]bool{}, - done: map[string]bool{}, + enabled: true, + seen: map[string]bool{}, + done: map[string]bool{}, + shown: map[string]bool{}, + lastHintTurn: -1, } } // newNoticeBoard loads the ledger and decides whether there is news. func newNoticeBoard(path, build string, enabled bool) noticeBoard { b := noticeBoard{ - ledger: loadNoticeLedger(path), - path: path, - build: build, - enabled: enabled, - seen: map[string]bool{}, - done: map[string]bool{}, + ledger: loadNoticeLedger(path), + path: path, + build: build, + enabled: enabled, + seen: map[string]bool{}, + done: map[string]bool{}, + shown: map[string]bool{}, + lastHintTurn: -1, } // A LEDGER FROM THE OLD COUNTING RULE IS FORGIVEN ONCE, on the way in // (notice_ledger.go's [noticeLedgerRule]): the tips it spent on flashes @@ -729,13 +728,64 @@ type noticeCandidate struct { // returns "" for nothing, and it changes nothing on the board but the // [noticeBoard.advance] it spends — [noticeBoard.take] records the decision. // -// IT IS A ROTATION AND NOT A RANKING: EVERY ELIGIBLE TIP HAS ITS TURN, in the -// table's order, round and round. The one standing keeps standing until the -// slot is asked to advance — or until it stops being eligible, when the next -// takes over at once so the row is never blank while there is something true -// to say. With one eligible tip the rotation is that tip; with none the row -// is empty. A retired notice, or one retired this session, is never a -// candidate. +// TWO BOXES, TWO RULES, ONE LIST (the owner's ruling, 2026-09-22). Home's row +// is a ROTATION and the conversation's is a RANKING, because the two rows are +// read in different ways: home is a screen somebody passes through, where +// every tip should get its turn; a conversation is a screen somebody sits in, +// where the tip worth saying is the one about what is happening RIGHT NOW. +// The conversation's rule is the one this surface shipped with and is restored +// here after a build that put it on the rotation with home. +func (b *noticeBoard) pick(slot noticeSlot, cands []noticeCandidate, turn int) string { + if slot == slotHint { + return b.rank(cands, turn) + } + return b.rotate(slot, cands) +} + +// rank is THE CONVERSATION'S RULE: the first eligible row in the table's own +// order takes the slot, and a row that has stopped being eligible stands down +// at once. The table's order IS the ranking — a row written above another is +// the more urgent thing to say — which is what the old `priority` column on +// every row said in numbers before the two boxes were merged onto one list. +// +// THE SLOT CHANGES HANDS SLOWLY, and that is what stops the ranking from +// reading as a slideshow. A different id may take it only once [noticeGap] +// turns have passed since it last changed, so three tips arming in three turns +// are read one at a time. The slot's first occupant of the session waits on +// nothing, and a slot going EMPTY never waits: a tip whose arming fact stopped +// being true stands down at once, whatever the gap says. +func (b *noticeBoard) rank(cands []noticeCandidate, turn int) string { + eligible := func(c noticeCandidate) bool { return c.armed && !b.done[c.id] && !b.retired(c.id) } + held := b.current[slotHint] + best := "" + for _, c := range cands { + if eligible(c) { + best = c.id + break + } + } + if best == "" { + return "" + } + if best != held && b.lastHintTurn >= 0 && turn-b.lastHintTurn < noticeGap { + // Too soon for a different line. The one standing keeps standing if it + // is still eligible, and the slot goes quiet otherwise. + for _, c := range cands { + if c.id == held && eligible(c) { + return held + } + } + return "" + } + return best +} + +// rotate is HOME'S RULE: EVERY ELIGIBLE TIP HAS ITS TURN, in the table's +// order, round and round. The one standing keeps standing until the slot is +// asked to advance — or until it stops being eligible, when the next takes +// over at once so the row is never blank while there is something true to +// say. With one eligible tip the rotation is that tip; with none the row is +// empty. A retired notice, or one retired this session, is never a candidate. // // THE ONE EXCEPTION IS A TIP THAT HAS JUST BECOME TRUE. It jumps the ring // whether or not the slot was asked to move: `/compact summarizes the @@ -743,7 +793,7 @@ type noticeCandidate struct { // of twenty tips would otherwise bring it round the best part of an hour // later. It jumps once — at the decision that first sees it armed — and then // takes its turn like every other row. -func (b *noticeBoard) pick(slot noticeSlot, cands []noticeCandidate) string { +func (b *noticeBoard) rotate(slot noticeSlot, cands []noticeCandidate) string { held := b.current[slot] eligible := func(c noticeCandidate) bool { return c.armed && !b.done[c.id] && !b.retired(c.id) } advance := b.advance[slot] @@ -787,12 +837,24 @@ func (b *noticeBoard) pick(slot noticeSlot, cands []noticeCandidate) string { // starts no standing, because what has not been read has not been shown // ([app.noticeLive]). Until 2026-09-22 every visible change of hands counted, // and [noticeReadTime] says what that cost. -func (b *noticeBoard) take(slot noticeSlot, id string, live bool, now time.Time, limitOf func(string) int) (changed, wrote bool) { +func (b *noticeBoard) take(slot noticeSlot, id string, live bool, now time.Time, limitOf func(string) int, turn int) (changed, wrote bool) { if b.current[slot] == id { return false, false } wrote = b.settle(slot, now, limitOf) b.current[slot] = id + if slot == slotHint { + // THE CONVERSATION'S ROW COUNTS ON ARRIVAL, ONCE PER SESSION. Its tip + // stands on the keys row for as long as the frame is quiet, with no + // clock over it and no cross to end it, so there is no departure to + // measure — and an hour in the slot is one showing, not one per event. + b.lastHintTurn = turn + if id == "" || b.shown[id] { + return true, wrote + } + b.shown[id] = true + return true, b.count(id, limitOf(id)) || wrote + } if id == "" || !live { return true, wrote } @@ -898,9 +960,9 @@ func (a *app) noticeFill(slot noticeSlot) bool { cands = append(cands, noticeCandidate{id: n.id, armed: armed, fresh: armed && !b.armed[slot][n.id]}) b.armed[slot][n.id] = armed } - id := b.pick(slot, cands) + id := b.pick(slot, cands, a.turn) now := a.now() - changed, wrote := b.take(slot, id, a.noticeLive(slot), now, a.noticeLimit) + changed, wrote := b.take(slot, id, a.noticeLive(slot), now, a.noticeLimit, a.turn) if changed { // A new tip is a new thing to read, so its clock starts again. THE // CROSS IS NOT SPENT HERE: a row somebody put away stays away until @@ -923,10 +985,14 @@ func (a *app) noticeFill(slot noticeSlot) bool { // what makes a change of hands a showing ([noticeBoard.take]): home's row // while home is in front, the conversation's once its quiet minute has passed. // -// A ROW WHOSE CROSS HAS BEEN PRESSED IS NOT LIVE. It draws nothing until the -// slot next changes hands ([noticeBoard.hidden]), and a tip standing behind a -// blank row is a tip nobody is reading — which is the whole of what -// [noticeReadTime] exists to tell apart. +// A ROW WHOSE CROSS HAS BEEN PRESSED IS NOT LIVE. It draws nothing until home +// goes out of view ([noticeBoard.hidden]), and a tip standing behind a blank +// row is a tip nobody is reading — which is the whole of what [noticeReadTime] +// exists to tell apart. +// +// THE CONVERSATION'S ROW IS NEVER LIVE IN THIS SENSE, because it does not +// measure a standing at all: its showing is counted when the slot takes a tip, +// once per session ([noticeBoard.take]). func (a *app) noticeLive(slot noticeSlot) bool { if a.notices.hidden[slot] { return false @@ -935,7 +1001,7 @@ func (a *app) noticeLive(slot noticeSlot) bool { case slotHome: return a.at(pageHome) case slotHint: - return a.showing() == nil && a.notices.due + return false } return true } @@ -1074,10 +1140,16 @@ func (a *app) noticeShow(slot noticeSlot, id string) { } } -// noticeHint is the line standing on the conversation's row: drawn only once -// the person has been quiet for [chatHintIdle] ([noticeBoard.due]), while the -// frame is quiet enough for a tip to be read over an idle box, and not while -// its cross has been pressed. +// noticeHint is the conversation's tip: the lowest rung of the KEYS ROW at the +// foot (render.go's [app.footHint]), drawn whenever the frame is quiet enough +// for a tip to be read over an idle box. +// +// IT IS ON NO CLOCK AND HAS NO CROSS. For one build on 2026-09-22 it had both +// — a row of its own over the rule, a quiet minute before it appeared, a +// two-minute rotation and a cross — and the owner put it back where it was: +// the foot, decided by the events that prove what is happening, shown while +// nothing is happening. Home's row keeps the newer shape; the two boxes are +// read differently and are allowed to differ ([noticeBoard.pick]). // // IT DRAWS OVER NOTHING THAT IS HAPPENING. A running turn, a list, a layer, a // box with words in it — each of those belongs to the thing being done, and @@ -1085,7 +1157,7 @@ func (a *app) noticeShow(slot noticeSlot, id string) { func (a *app) noticeHint() string { b := &a.notices id := b.current[slotHint] - if id == "" || !b.enabled || !b.due || b.hidden[slotHint] || !a.noticeQuiet() { + if id == "" || !b.enabled || !a.noticeQuiet() { return "" } return a.chords.say(a.noticeLine(id)) @@ -1098,99 +1170,6 @@ func (a *app) noticeQuiet() bool { !a.pick.open && !a.roster.open && !a.asking() && !a.roomOpen() } -// ── THE CONVERSATION'S CLOCK ──────────────────────────────────────────────── -// -// A conversation's row is on a clock rather than on events, because what it -// waits for is an absence: nothing pressed and nothing landing for -// [chatHintIdle]. ONE TIMER IS PENDING AT A TIME, AND IT KEEPS ITSELF GOING. -// A key does not arm a clock of its own — a thousand keystrokes would be a -// thousand sleeping goroutines — it stamps [noticeBoard.touched], and the one -// clock, when it lands, measures from the stamp and goes back to sleep for -// what is left ([app.noticeIdleBeat]). It is started once, when the surface -// comes up (app.go's [app.Init]), and every beat arms the next: over a place, -// with hints off, or with nothing to say it simply sleeps the minute again. -// That is one goroutine parked a minute at a time, which is what makes a -// conversation opened from home and then simply read find its tip a minute -// later, without any road into a conversation having to remember to wind it. - -// hintTickMsg is the conversation's clock landing, carrying the arming it -// belongs to. -type hintTickMsg struct{ gen int } - -// hintTick schedules the conversation's clock. -func hintTick(gen int, after time.Duration) tea.Cmd { - return surfaceTick(after, func(time.Time) tea.Msg { return hintTickMsg{gen: gen} }) -} - -// noticeTouched is the person doing something in front of the surface — a key -// pressed anywhere, a turn ending. The tip stands down and the idle clock -// starts again from now. -func (a *app) noticeTouched() { - b := &a.notices - if b.seen == nil { - *b = bareNoticeBoard() - } - b.touched = a.now() - if b.due { - // The row goes out of sight with the key, so the tip on it has stood - // for as long as it is going to. - b.due = false - a.noticeSettle(slotHint) - a.touch() - } -} - -// noticeArmIdle starts the conversation's clock, once: a second call while a -// beat is pending answers nil. -func (a *app) noticeArmIdle() tea.Cmd { - b := &a.notices - if b.seen == nil { - *b = bareNoticeBoard() - } - if b.idleArmed { - return nil - } - if b.touched.IsZero() { - b.touched = a.now() - } - b.idleArmed = true - b.idleGen++ - return hintTick(b.idleGen, chatHintIdle) -} - -// noticeIdleBeat is the clock landing, and every beat arms the next. A beat -// from an older arming is dropped. One that finds a place in front, or hints -// off, sleeps the minute again; one that finds the person active goes back to -// sleep for what is left of the minute; one that finds them quiet shows the -// row's tip — and, on every beat after that, moves the row on ([hintEvery]), -// until a key or a turn stamps the board again. -func (a *app) noticeIdleBeat(gen int) tea.Cmd { - b := &a.notices - if gen != b.idleGen { - return nil - } - if !b.enabled || a.showing() != nil { - return hintTick(gen, chatHintIdle) - } - if since := a.now().Sub(b.touched); since < chatHintIdle { - return hintTick(gen, chatHintIdle-since) - } - if b.due { - a.noticeRotate(slotHint) - } else { - // THE TIP STANDING BECOMES VISIBLE NOW, so its standing starts now; - // whether it was a showing is known when it leaves. - b.due = true - b.hidden[slotHint] = false - b.visible(slotHint, a.now()) - a.touch() - } - if b.current[slotHint] == "" { - return hintTick(gen, chatHintIdle) - } - return hintTick(gen, hintEvery) -} - // lastAnswerRunes is how long the newest finished answer is — the fact the // rewind hint arms on. It walks back from the end and stops at the first answer, // so a long conversation costs no more than a short one. diff --git a/internal/tui3/notice_test.go b/internal/tui3/notice_test.go index 96d5558a0b..ab25e8bf5b 100644 --- a/internal/tui3/notice_test.go +++ b/internal/tui3/notice_test.go @@ -133,24 +133,22 @@ func TestAHintAgesOutAcrossOrdinaryLaunches(t *testing.T) { // The task tip is the one a first exchange arms highest (notice.go's // table); `/ shows every command` stood here until both feet said it. const hint = "task-in-chat" - // A SHOWING IS A VISIBLE ONE THAT STOOD: the row draws after a quiet - // minute, so each launch is a turn ending, a minute of nothing, the tip - // standing [noticeReadTime], and then a key — which puts the row away and - // is when the showing is counted (chattip_test.go proves the clock; here - // it is turned by hand). - launch := func() (*app, func(time.Duration)) { + // A SHOWING ON THE CONVERSATION'S ROW IS THE SLOT TAKING THE TIP, counted + // once per session however many events re-decide it — so each launch is one + // turn ending, and the ledger on disk has one more showing after it. + launch := func() *app { a := noticeApp(t, "") a.turn = 1 a.noticeEvent(eventTurnEnded) - return a, quietMinute(a) + return a } for session := 1; session <= noticeShownDefault; session++ { - a, stand := launch() + a := launch() if got := a.notices.current[hintSlotForTest]; got != hint { t.Fatalf("launch %d holds %q in the hint slot, want %q", session, got, hint) } - stand(noticeReadTime) - a.noticeTouched() + // A second event in the same session counts nothing more. + a.noticeEvent(eventTurnEnded) if got := loadNoticeLedger(noticeLedgerPath("")).shown(hint); got != session { t.Fatalf("after launch %d the ledger on disk counts %d showings", session, got) } @@ -158,7 +156,7 @@ func TestAHintAgesOutAcrossOrdinaryLaunches(t *testing.T) { if got := loadNoticeLedger(noticeLedgerPath("")); !got.retired(hint) { t.Fatalf("the hint is not retired after %d launches: %+v", noticeShownDefault, got) } - if a, _ := launch(); a.notices.current[hintSlotForTest] == hint { + if a := launch(); a.notices.current[hintSlotForTest] == hint { t.Fatalf("a hint shown in %d launches came back in the next one", noticeShownDefault) } } @@ -167,19 +165,6 @@ func TestAHintAgesOutAcrossOrdinaryLaunches(t *testing.T) { // assertions above read as sentences. const hintSlotForTest = slotHint -// quietMinute turns the conversation's clock by hand until the row's tip is -// due (notice.go's [app.noticeIdleBeat]), and hands back the hand that moves -// that clock on, for a test that needs the tip to stand a while. -func quietMinute(a *app) func(time.Duration) { - now := time.Date(2026, 9, 22, 12, 0, 0, 0, time.UTC) - a.clock = func() time.Time { return now } - a.noticeTouched() - a.noticeArmIdle() - now = now.Add(chatHintIdle) - a.noticeIdleBeat(a.notices.idleGen) - return func(d time.Duration) { now = now.Add(d) } -} - // AND THE NEWS CHANNEL HAS AN OLDER BUILD TO COMPARE AGAINST. It opens on the // second build a profile meets, which on an ordinary launch it never did: the // first build was never written down, so every launch was a first launch and a @@ -285,10 +270,11 @@ func freshBoard() noticeBoard { return newNoticeBoard("", "", true) } // tests that need no table. func fixedLimit(n int) func(string) int { return func(string) int { return n } } -// A ROTATION, NOT A RANKING: the first eligible tip stands, an event without -// an advance keeps it, an advance moves to the next eligible in the table's -// order, and the ring comes round. -func TestASlotRotatesThroughTheEligibleTipsInTableOrder(t *testing.T) { +// HOME'S ROW IS A ROTATION AND NOT A RANKING: the first eligible tip stands, +// an event without an advance keeps it, an advance moves to the next eligible +// in the table's order, and the ring comes round. (The conversation's row is +// the ranking, below.) +func TestHomesRowRotatesThroughTheEligibleTipsInTableOrder(t *testing.T) { b := freshBoard() now := time.Date(2026, 9, 22, 9, 0, 0, 0, time.UTC) cands := []noticeCandidate{ @@ -297,29 +283,29 @@ func TestASlotRotatesThroughTheEligibleTipsInTableOrder(t *testing.T) { {id: "second", armed: true}, {id: "third", armed: true}, } - if got := b.pick(slotHint, cands); got != "first" { + if got := b.pick(slotHome, cands, 0); got != "first" { t.Fatalf("the slot picked %q, want the first eligible", got) } - b.take(slotHint, "first", true, now, fixedLimit(6)) - if got := b.pick(slotHint, cands); got != "first" { + b.take(slotHome, "first", true, now, fixedLimit(6), 0) + if got := b.pick(slotHome, cands, 0); got != "first" { t.Fatalf("an event without an advance moved the slot to %q", got) } for _, want := range []string{"second", "third", "first"} { - b.advance[slotHint] = true - got := b.pick(slotHint, cands) + b.advance[slotHome] = true + got := b.pick(slotHome, cands, 0) if got != want { t.Fatalf("the ring went to %q, want %q", got, want) } - b.take(slotHint, got, true, now, fixedLimit(6)) + b.take(slotHome, got, true, now, fixedLimit(6), 0) } // The note slot rotates on the same terms; with one candidate it is that one. - if got := b.pick(slotNote, []noticeCandidate{{id: "news", armed: true}}); got != "news" { + if got := b.pick(slotNote, []noticeCandidate{{id: "news", armed: true}}, 0); got != "news" { t.Fatalf("the note slot said %q", got) } } -// A TIP THAT HAS JUST BECOME TRUE JUMPS THE RING, once, whether or not the slot -// was asked to move — and then takes its turn like every other row. +// A TIP THAT HAS JUST BECOME TRUE JUMPS HOME'S RING, once, whether or not the +// slot was asked to move — and then takes its turn like every other row. func TestAFreshTipJumpsTheRing(t *testing.T) { b := freshBoard() now := time.Date(2026, 9, 22, 9, 0, 0, 0, time.UTC) @@ -328,34 +314,34 @@ func TestAFreshTipJumpsTheRing(t *testing.T) { {id: "first", armed: true}, {id: "second", armed: true}, } - if got := b.pick(slotHint, cands); got != "first" { + if got := b.pick(slotHome, cands, 0); got != "first" { t.Fatalf("the slot picked %q", got) } - b.take(slotHint, "first", true, now, fixedLimit(6)) + b.take(slotHome, "first", true, now, fixedLimit(6), 0) cands[0] = noticeCandidate{id: "compact", armed: true, fresh: true} - if got := b.pick(slotHint, cands); got != "compact" { + if got := b.pick(slotHome, cands, 0); got != "compact" { t.Fatalf("a fresh tip did not jump the ring: %q", got) } - b.take(slotHint, "compact", true, now, fixedLimit(6)) + b.take(slotHome, "compact", true, now, fixedLimit(6), 0) // No longer fresh: an event keeps it, and an advance walks on from it. cands[0].fresh = false - if got := b.pick(slotHint, cands); got != "compact" { + if got := b.pick(slotHome, cands, 0); got != "compact" { t.Fatalf("a tip that had jumped was moved by an event: %q", got) } - b.advance[slotHint] = true - if got := b.pick(slotHint, cands); got != "first" { + b.advance[slotHome] = true + if got := b.pick(slotHome, cands, 0); got != "first" { t.Fatalf("the ring did not walk on from the fresh tip: %q", got) } // A tip disarming stands down at once, for the next eligible. - b.take(slotHint, "first", true, now, fixedLimit(6)) + b.take(slotHome, "first", true, now, fixedLimit(6), 0) cands[1].armed = false - if got := b.pick(slotHint, cands); got != "second" { + if got := b.pick(slotHome, cands, 0); got != "second" { t.Fatalf("a disarmed tip did not yield: %q", got) } } -// A SHOWING IS A TIP THAT STOOD TWENTY SECONDS ON A VISIBLE ROW. A flash on the -// way through is nothing; a tip that stood is counted when it leaves; a tip +// A SHOWING ON HOME'S ROW IS A TIP THAT STOOD TWENTY SECONDS WHERE IT COULD BE +// SEEN. A flash on the way through is nothing; a tip that stood is counted when it leaves; a tip // decided while the row could not be seen counts nothing until the row comes // into view; and the last allowed showing retires the notice. func TestAShowingIsATipThatStoodLongEnoughToBeRead(t *testing.T) { @@ -363,34 +349,34 @@ func TestAShowingIsATipThatStoodLongEnoughToBeRead(t *testing.T) { now := time.Date(2026, 9, 22, 9, 0, 0, 0, time.UTC) limit := fixedLimit(3) // A flash: the tip leaves five seconds after it came. - b.take(slotHint, "tip", true, now, limit) + b.take(slotHome, "tip", true, now, limit, 0) now = now.Add(5 * time.Second) - b.take(slotHint, "", true, now, limit) + b.take(slotHome, "", true, now, limit, 0) if got := b.ledger.shown("tip"); got != 0 { t.Fatalf("a five-second flash counted %d showings", got) } // Re-deciding the same tip is nothing, and standing twenty seconds is one. - b.take(slotHint, "tip", true, now, limit) - b.take(slotHint, "tip", true, now, limit) + b.take(slotHome, "tip", true, now, limit, 0) + b.take(slotHome, "tip", true, now, limit, 0) now = now.Add(noticeReadTime) - if b.settle(slotHint, now, limit); b.ledger.shown("tip") != 1 { + if b.settle(slotHome, now, limit); b.ledger.shown("tip") != 1 { t.Fatalf("a tip that stood %s counted %d showings, want 1", noticeReadTime, b.ledger.shown("tip")) } // A settled tip does not count again until it is seen again. now = now.Add(time.Minute) - if b.settle(slotHint, now, limit); b.ledger.shown("tip") != 1 { + if b.settle(slotHome, now, limit); b.ledger.shown("tip") != 1 { t.Fatalf("a settled tip counted again: %d", b.ledger.shown("tip")) } // Decided while the row is out of sight: no standing until it is visible. - b.take(slotHint, "", false, now, limit) - b.take(slotHint, "tip", false, now, limit) + b.take(slotHome, "", false, now, limit, 0) + b.take(slotHome, "tip", false, now, limit, 0) now = now.Add(time.Hour) - if b.settle(slotHint, now, limit); b.ledger.shown("tip") != 1 { + if b.settle(slotHome, now, limit); b.ledger.shown("tip") != 1 { t.Fatalf("a tip nobody could see counted: %d", b.ledger.shown("tip")) } - b.visible(slotHint, now) + b.visible(slotHome, now) now = now.Add(noticeReadTime) - b.take(slotHint, "other", true, now, limit) + b.take(slotHome, "other", true, now, limit, 0) if got := b.ledger.shown("tip"); got != 2 { t.Fatalf("the tip counted %d showings after coming into view and standing, want 2", got) } @@ -401,7 +387,7 @@ func TestAShowingIsATipThatStoodLongEnoughToBeRead(t *testing.T) { // and the slot is cleared as the notice retires. next := newNoticeBoard("", "", true) next.ledger = b.ledger - next.take(slotHome, "tip", true, now, limit) + next.take(slotHome, "tip", true, now, limit, 0) now = now.Add(noticeReadTime) next.settle(slotHome, now, limit) if !next.retired("tip") { @@ -414,12 +400,12 @@ func TestAShowingIsATipThatStoodLongEnoughToBeRead(t *testing.T) { func TestARetiredNoticeNeverReturnsThisSession(t *testing.T) { b := freshBoard() cands := []noticeCandidate{{id: "tip", armed: true}} - b.take(slotHint, "tip", true, time.Now(), fixedLimit(3)) + b.take(slotHint, "tip", true, time.Now(), fixedLimit(3), 0) b.retire("tip") if b.current[slotHint] != "" { t.Fatal("retiring did not clear the slot") } - if got := b.pick(slotHint, cands); got != "" { + if got := b.pick(slotHint, cands, 0); got != "" { t.Fatalf("a retired notice came back: %q", got) } } @@ -546,18 +532,14 @@ func TestAHintArmsDrawsLowestRetiresAndStaysRetired(t *testing.T) { if got := a.notices.current[slotHint]; got != "task-page-after-first-task" { t.Fatalf("a task starting armed %q", got) } - // THE ROW WAITS FOR A QUIET MINUTE (notice.go's THE CONVERSATION'S CLOCK); - // the clock itself is proved in chattip_test.go, and here the minute is - // taken as passed. - if got := a.noticeHint(); got != "" { - t.Fatalf("the tip drew before the person had been quiet: %q", got) - } - a.notices.due = true + // AND IT IS UP THE MOMENT IT ARMS, on the keys row at the foot: the + // conversation's tip is on no clock (chattip_test.go holds the whole of + // where it draws). if got := a.noticeHint(); got != taskPageTip { t.Fatalf("the tip row reads %q, want the tip", got) } - if got := a.footHint(a.width); strings.Contains(got, taskPageTip) { - t.Fatalf("the keys row still carries the tip: %q", got) + if got := plain(a.footHint(a.width)); !strings.Contains(got, taskPageTip) { + t.Fatalf("the keys row does not carry the tip: %q", got) } if !strings.Contains(plain(frame(a)), taskPageTip) { t.Fatalf("the tip is not on the frame:\n%s", plain(frame(a))) @@ -610,7 +592,6 @@ func TestAHintArmsDrawsLowestRetiresAndStaysRetired(t *testing.T) { if got := again.notices.current[slotHint]; got == "task-page-after-first-task" { t.Fatal("a retired hint came back after a restart") } - again.notices.due = true if strings.Contains(plain(frame(again)), taskPageTip) { t.Fatal("the tip is drawn after a restart") } @@ -750,7 +731,6 @@ func TestTheCompactHintFollowsTheContextReading(t *testing.T) { func TestTheHintsRowSilencesTheSlot(t *testing.T) { a, dir := sheetApp(t) startTask(t, a) - a.notices.due = true if got := a.noticeHint(); got != taskPageTip { t.Fatalf("the tip row reads %q before the toggle", got) } @@ -906,3 +886,110 @@ func TestUnreadProfileKeysNoticeTracksTheSet(t *testing.T) { t.Fatal("previous unread set showed again") } } + +// ── THE CONVERSATION'S RULE ───────────────────────────────────────────────── + +// A RANKING, NOT A ROTATION: the first eligible row in the table's order takes +// the conversation's slot, the one already standing wins its own tie, and a row +// that stops being eligible stands down at once. This is the rule this surface +// shipped with, restored on 2026-09-22 after a build that put the conversation +// on home's rotation. +func TestTheConversationsSlotTakesTheFirstEligibleInTableOrder(t *testing.T) { + b := freshBoard() + cands := []noticeCandidate{ + {id: "first", armed: false}, + {id: "second", armed: true}, + {id: "third", armed: true}, + } + if got := b.pick(slotHint, cands, 0); got != "second" { + t.Fatalf("the slot picked %q, want the first eligible in table order", got) + } + b.take(slotHint, "second", false, time.Time{}, fixedLimit(6), 0) + + // A row ABOVE the one standing is the more urgent thing to say, and takes + // the slot once the gap has passed — the table's order is the ranking. + cands[0].armed = true + if got := b.pick(slotHint, cands, noticeGap); got != "first" { + t.Fatalf("a row above the one standing did not take the slot: %q", got) + } + // And the one standing yields at once when it stops being true, whatever + // else is armed. + cands[0].armed = false + cands[1].armed = false + if got := b.pick(slotHint, cands, noticeGap); got != "third" { + t.Fatalf("a row that stopped being true did not yield: %q", got) + } + // With nothing eligible the row is empty. + for i := range cands { + cands[i].armed = false + } + if got := b.pick(slotHint, cands, noticeGap); got != "" { + t.Fatalf("an empty list still says %q", got) + } +} + +// THE CONVERSATION'S SLOT CHANGES HANDS SLOWLY. A different line may take it +// only once [noticeGap] turns have passed, so three tips arming in three turns +// are read one at a time. The first occupant of the session waits on nothing, +// and a slot going empty never waits. +func TestTheConversationsSlotChangesHandsSlowly(t *testing.T) { + b := freshBoard() + first := []noticeCandidate{{id: "first", armed: true}} + if got := b.pick(slotHint, first, 1); got != "first" { + t.Fatalf("the first tip of the session waited: %q", got) + } + b.take(slotHint, "first", false, time.Time{}, fixedLimit(6), 1) + + both := []noticeCandidate{{id: "second", armed: true}, {id: "first", armed: true}} + if got := b.pick(slotHint, both, 1+noticeGap-1); got != "first" { + t.Fatalf("the slot changed hands inside the gap: %q", got) + } + if got := b.pick(slotHint, both, 1+noticeGap); got != "second" { + t.Fatalf("the slot did not change hands after the gap: %q", got) + } + + // Inside the gap, a standing tip that stopped being armed stands down at + // once and the slot goes quiet rather than jumping to the next one. + b = freshBoard() + b.take(slotHint, "first", false, time.Time{}, fixedLimit(6), 1) + gone := []noticeCandidate{{id: "second", armed: true}, {id: "first", armed: false}} + if got := b.pick(slotHint, gone, 1); got != "" { + t.Fatalf("a disarmed tip was replaced inside the gap: %q", got) + } + + // Home's row is not on turns at all: it is on visits and a clock. + b = freshBoard() + b.take(slotHint, "first", false, time.Time{}, fixedLimit(6), 1) + b.advance[slotHome] = true + if got := b.pick(slotHome, both, 1); got != "second" { + t.Fatalf("home's row waited on the conversation's gap: %q", got) + } +} + +// A TIP IS COUNTED ONCE PER SESSION ON THE CONVERSATION'S ROW, however many +// events re-decide the slot, and its last allowed showing retires it for the +// sessions after while leaving it up for this one. +func TestTheConversationsRowCountsOnceASession(t *testing.T) { + b := freshBoard() + limit := fixedLimit(2) + b.take(slotHint, "tip", false, time.Time{}, limit, 1) + b.take(slotHint, "", false, time.Time{}, limit, 2) + b.take(slotHint, "tip", false, time.Time{}, limit, 3) + if got := b.ledger.shown("tip"); got != 1 { + t.Fatalf("one session counted %d showings", got) + } + if b.retired("tip") { + t.Fatal("a first showing retired the notice") + } + + // The next session: the second showing is the last allowed. + next := newNoticeBoard("", "", true) + next.ledger = b.ledger + next.take(slotHint, "tip", false, time.Time{}, limit, 1) + if !next.retired("tip") { + t.Fatal("the last allowed showing did not retire the notice") + } + if next.current[slotHint] != "tip" { + t.Fatal("the last allowed showing was not shown") + } +} diff --git a/internal/tui3/projectseam.go b/internal/tui3/projectseam.go index 742be96f63..4db45ecf46 100644 --- a/internal/tui3/projectseam.go +++ b/internal/tui3/projectseam.go @@ -60,19 +60,11 @@ func (a *app) seamProjectPress(x, y int) (tea.Cmd, bool) { return a.openFolderPick(""), true } -// tipClosePress is a press on the cross at the end of the conversation's tip -// row (view.go's [chromeTip]): the row moves on to the next tip, and the one -// put away keeps its whole allowance (notice.go's [app.noticeDismiss]). It -// reports whether it took the press; the rest of that row is blank, and blank -// is not a gesture. -func (a *app) tipClosePress(x, y int) bool { - mark, ok := a.chromeAt(y) - if !ok || mark.kind != chromeTip || !a.tipCloseSpan.holds(x) { - return false - } - a.noticeDismiss(slotHint) - return true -} +// THERE IS NO CROSS ON A CONVERSATION'S TIP. `tipClosePress` stood here for +// one build on 2026-09-22, while the tip had a row of its own over the rule; +// the tip is the keys row's lowest rung again (render.go's [app.footHint]) and +// the keys row has never had one. Home's row keeps its cross (home.go's +// [app.homePress]). // seamModelPaint keeps the current model bold and bright even while underlined. func seamModelPaint(pal palette, text string, hovered bool) string { diff --git a/internal/tui3/render.go b/internal/tui3/render.go index 7a5e4b9375..61725ec8f3 100644 --- a/internal/tui3/render.go +++ b/internal/tui3/render.go @@ -3680,10 +3680,19 @@ func (a *app) footHint(width int) string { if a.chordLost && a.chords.meta == chordMetaWord { return a.chords.chordShortWords() } - // THE EARNED TIP IS NOT ON THIS ROW ANY MORE. Until 2026-09-22 it was the - // rung under the rest state here; it has a row of its own now, over the - // rule, once the person has been quiet for a minute (notice.go's - // [app.noticeHint]), so the keys row is the keys and nothing else. + // AND UNDER EVERY STATE'S OWN KEYS, THE EARNED TIP (notice.go). It is the + // lowest rung there is — a tip about a gesture the person has not used yet, + // drawn only over an idle box — and it takes the slot from the rest state + // below because that is what the rest state is for: the one line a newcomer + // reads when nothing is happening. + // + // IT LEFT THIS ROW FOR ONE BUILD ON 2026-09-22, for a row of its own over + // the rule with a clock and a cross, and the owner put it back here. Home's + // row keeps that newer shape; the two boxes are read differently and are + // allowed to differ (notice.go's [noticeBoard.pick]). + if tip := a.noticeHint(); tip != "" { + return tip + } return a.idleHint() } diff --git a/internal/tui3/view.go b/internal/tui3/view.go index 67b04b1a67..e7c41500a8 100644 --- a/internal/tui3/view.go +++ b/internal/tui3/view.go @@ -141,11 +141,6 @@ const ( // is EMPTY apart from the chip, and the chip is right-aligned, so a press on // it is a question about the column as well as the row (jumpchip.go). chromeJump - // chromeTip is the same gap row carrying the tip instead, once the person - // has been quiet for a minute (notice.go). The row is EMPTY apart from the - // tip, right-aligned, and the one thing on it a person can press is the - // cross at its end (projectseam.go's [app.tipClosePress]). - chromeTip // chromeLegend is the rule between the transcript and the box. Its right // end carries the hint slot, and the one thing in that slot a person can // press is the door home (home.go's [app.homeDoorPress]). @@ -680,29 +675,15 @@ func (a *app) chrome(width int) ([]string, []chromeRow, int, int) { chip := a.jumpChip(width) jumped := false // addGap spends one row of the ladder, and hands it to the chip if the chip - // has not been placed yet. - // AND THE TIP RIDES THE SAME ROW WHEN THE CHIP DOES NOT (notice.go's - // [app.noticeHint]): the chip is a door back to the live edge and outranks - // a sentence; the tip is laid out as home's is (hometip.go's [app.tipLine]), - // and its cross's columns are written here, as the row is laid out. - a.tipCloseSpan = hudSpan{} - tipRow, tipSpan := "", hudSpan{} - if tip := a.noticeHint(); tip != "" { - tipRow, tipSpan = a.tipLine(tip, width, a.pal) - } - tipped := false + // has not been placed yet. THE TIP DOES NOT RIDE THIS ROW: it is the keys + // row's lowest rung at the foot (render.go's [app.footHint]), which is + // where it was before 2026-09-22 and where the owner put it back. addGap := func() { if chip != "" && !jumped { jumped = true add(chip, chromeRow{kind: chromeJump}) return } - if tipRow != "" && !tipped { - tipped = true - a.tipCloseSpan = tipSpan - add(tipRow, chromeRow{kind: chromeTip}) - return - } add("", chromeRow{}) } // THE GREETING IS THE HEAD OF THIS BLOCK AND, WHILE IT IS UP, IT IS MOST OF From 50c134a67b4933b65b5a6b9e3a636738da4b4779 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:55:59 -0400 Subject: [PATCH 23/39] chat: the /manual tip stops at "about codeaf" `from its own manual` was the interesting half to whoever wrote the row and the uninteresting half to whoever reads it: somebody who has not used /manual wants to know they can ask, not where the answer comes from. Same id, same gesture. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- internal/manual/chat/hints-and-tips.md | 2 +- internal/tui3/notice.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/manual/chat/hints-and-tips.md b/internal/manual/chat/hints-and-tips.md index d7a4cfe6bf..3870ffc620 100644 --- a/internal/manual/chat/hints-and-tips.md +++ b/internal/manual/chat/hints-and-tips.md @@ -118,7 +118,7 @@ build if the two disagree), so a tip you saw is on it word for word. `/task` is typed, bare or with a brief. - `ctrl+enter sends your message as something to keep true` — retired when a standing order is made or the standing page opened. -- `/manual answers any question about codeaf from its own manual` — retired when +- `/manual answers any question about codeaf` — retired when `/manual` is typed, bare or with a question. - `ctrl+shift+t reopens the last closed conversation tab` — retired the first time the chord is pressed, on a terminal that can send it. diff --git a/internal/tui3/notice.go b/internal/tui3/notice.go index ae3265def1..251e096f15 100644 --- a/internal/tui3/notice.go +++ b/internal/tui3/notice.go @@ -390,7 +390,7 @@ var notices = []notice{ { id: "manual-answers", slot: slotHint, armed: ready, - text: "/manual answers any question about codeaf from its own manual", + text: "/manual answers any question about codeaf", retire: eventManualAsked, }, { From 83a824fef3575c0311229208759353b987e9f17a Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Tue, 22 Sep 2026 22:14:48 -0400 Subject: [PATCH 24/39] =?UTF-8?q?chat:=20the=20@=20tip=20says=20what=20the?= =?UTF-8?q?=20list=20is=20for=20=E2=80=94=20finding=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `type @ to quickly attach files in the current project` named the wrong half of what the list does: it completes a file, a folder or a task INTO the sentence being written, and attaching is /attach's job on the tray. `find paths` is what a person is actually doing when they reach for it. Same id, same gesture. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- internal/manual/chat/hints-and-tips.md | 2 +- internal/tui3/notice.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/manual/chat/hints-and-tips.md b/internal/manual/chat/hints-and-tips.md index 3870ffc620..928dc4ba6d 100644 --- a/internal/manual/chat/hints-and-tips.md +++ b/internal/manual/chat/hints-and-tips.md @@ -125,7 +125,7 @@ build if the two disagree), so a tip you saw is on it word for word. **Files and context** -- `type @ to quickly attach files in the current project` — retired when the `@` list +- `type @ to find paths in the current project` — retired when the `@` list opens. - `/attach sends a file or folder with your message` — retired when a file or a folder goes on by path, or the browser opens. diff --git a/internal/tui3/notice.go b/internal/tui3/notice.go index 251e096f15..44853000d5 100644 --- a/internal/tui3/notice.go +++ b/internal/tui3/notice.go @@ -403,7 +403,7 @@ var notices = []notice{ { id: "at-completion", slot: slotHint, armed: ready, - text: "type @ to quickly attach files in the current project", + text: "type @ to find paths in the current project", retire: eventAtOpened, }, { From f64fe034ef623c24dc5cba965321b1ff2a72e128 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Tue, 22 Sep 2026 22:21:47 -0400 Subject: [PATCH 25/39] chat: /remember and /standing stop saying the same word MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both rows said "keeps", which taught a person that the two commands were one thing in two spellings: /standing keeps something always true /remember keeps one thing across conversations They are not. A standing order is a CONDITION the work has to honour — it rides into a task's brief under its own heading, and the worker reports when it cannot meet one. A memory is a fact carried forward as context, binding nothing. So the two rows now say which is which, and the memory one names the way back out: /standing turns a sentence into a rule work must follow /remember carries a fact forward, /forget drops it Same ids, same gestures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- internal/manual/chat/hints-and-tips.md | 7 +++++-- internal/tui3/notice.go | 15 +++++++++++---- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/internal/manual/chat/hints-and-tips.md b/internal/manual/chat/hints-and-tips.md index 928dc4ba6d..5401ba5384 100644 --- a/internal/manual/chat/hints-and-tips.md +++ b/internal/manual/chat/hints-and-tips.md @@ -112,7 +112,7 @@ build if the two disagree), so a tip you saw is on it word for word. when you run `/files`. - `/resume opens an earlier conversation` — when you start in a directory that already has a conversation. Retired when you run `/resume`. -- `/standing keeps something always true` — once this directory has three or more earlier +- `/standing turns a sentence into a rule work must follow` — once this directory has three or more earlier conversations. Retired when a standing order is made or the standing page opened. - `/task starts work you can walk away from` — after the first exchange. Retired when `/task` is typed, bare or with a brief. @@ -155,7 +155,10 @@ build if the two disagree), so a tip you saw is on it word for word. **Memory, accounts and the rest** -- `/remember keeps one thing across conversations` — retired when `/remember` is typed. +- `/remember carries a fact forward, /forget drops it` — retired when `/remember` is typed. + It is the only row that names two commands as a pair, because the two rows about keeping + something used to be told apart by nothing: a standing order is a condition the work has + to honour and a memory is a fact carried forward. - `/connect links Notion, Slack and other services` — retired when the connect panel is reached for. - `/autonomy sets how questions are handled while you are away` — after the first diff --git a/internal/tui3/notice.go b/internal/tui3/notice.go index 44853000d5..b14dfcad89 100644 --- a/internal/tui3/notice.go +++ b/internal/tui3/notice.go @@ -365,8 +365,14 @@ var notices = []notice{ }, { id: "standing-after-several-sessions", slot: slotHint, - armed: func(a *app) bool { return len(a.welcome.recent) >= 3 }, - text: "/standing keeps something always true", + armed: func(a *app) bool { return len(a.welcome.recent) >= 3 }, + // THE TWO "KEEPS" ROWS ARE TOLD APART SINCE 2026-09-22. This one and + // `/remember` both said "keeps", which taught a person that the two + // commands did the same thing in different words. They do not: a + // standing order is a CONDITION the work has to honour — it rides into + // a task's brief under its own heading and the worker reports when it + // cannot meet one — and a memory is a fact carried forward. + text: "/standing turns a sentence into a rule work must follow", retire: eventStandingOpened, }, // ── starting work ─────────────────────────────────────────────────────── @@ -475,8 +481,9 @@ var notices = []notice{ // ── memory, accounts and the rest ─────────────────────────────────────── { id: "remember-one-thing", slot: slotHint, - armed: ready, - text: "/remember keeps one thing across conversations", + armed: ready, + // The other half of the pair above: a fact, and the way back out of it. + text: "/remember carries a fact forward, /forget drops it", retire: eventRemembered, }, { From ba5c0bfa9956f4d78a9c1a6f151d33b0258d2f55 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Tue, 22 Sep 2026 22:46:25 -0400 Subject: [PATCH 26/39] =?UTF-8?q?chat:=20twenty-two=20tips=20=E2=80=94=20c?= =?UTF-8?q?trl+.=20comes=20off=20the=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner read the whole list a third time and took `ctrl+. sees every task this project has run` off it. The chord, `/history` and the task page are untouched; only the tip is gone, and `eventTaskStarted` and `eventTaskPageOpened` are still fired at their seams for a later row to wait on. The tip was this suite's fixture on both boxes — a row armed by something that happens mid-session and retired by a different gesture — so the tests move to `/files finds everything made for you`, armed by an export landing and retired by `/files`. Three comments in notice.go still described the machinery the conversation's row had for one build and no longer has: a quiet minute, an idle beat and a rotation. They now say what the file does — a conversation ranks, home takes turns. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- internal/manual/chat/hints-and-tips.md | 19 +++---- internal/tui3/chattip_test.go | 20 +++---- internal/tui3/hometip_test.go | 14 ++--- internal/tui3/notice.go | 71 ++++++++++++------------- internal/tui3/notice_test.go | 72 ++++++++++++++------------ 5 files changed, 99 insertions(+), 97 deletions(-) diff --git a/internal/manual/chat/hints-and-tips.md b/internal/manual/chat/hints-and-tips.md index 5401ba5384..30bb6fbf2a 100644 --- a/internal/manual/chat/hints-and-tips.md +++ b/internal/manual/chat/hints-and-tips.md @@ -3,7 +3,7 @@ ## What was that tip at the bottom of the screen — the one-line hint, the sentence under the message box The dim sentence at the very bottom of a conversation, where the keys usually are — -`ctrl+. sees every task this project has run` — is a **tip**: one line naming a key or a +`/files finds everything made for you` — is a **tip**: one line naming a key or a command you have not used yet, and what it does. It reads the way every hint on this surface does: the key or the command first, then what it does. @@ -21,7 +21,7 @@ it until you leave home and come back. A conversation is a screen you sit in and screen you pass through, so the tip worth saying differs: a conversation gets the most urgent thing that is true right now, and home gets everything in turn. -Both rows draw from **one list** of twenty-three tips (below), and using a gesture on +Both rows draw from **one list** of twenty-two tips (below), and using a gesture on either retires it on both. **A conversation's tip has changed places twice.** It was the keys row's lowest rung until @@ -50,8 +50,8 @@ put away nothing. Every tip is earned and then spent. It appears the first time it becomes relevant — the first task you start, the first long answer, the first time a conversation passes half its context window, or simply the first time home is open — and it goes away for good the first -time you do the thing it names. Open the task page once and `ctrl+. sees every task this -project has run` never comes back; run `/compact` once and the compact tip is retired. A tip +time you do the thing it names. Run `/files` once and `/files finds everything made for +you` never comes back; run `/compact` once and the compact tip is retired. A tip retired from either box is retired from both: opening the model list on home retires `/model lists every model` in every conversation as well. @@ -94,7 +94,7 @@ later when the ring comes round. It jumps once and then takes its turn like the ## Every hint codeaf can show, and what makes each one go away -There are twenty-three, one list for both boxes. Each one says the moment it first appears +There are twenty-two, one list for both boxes. Each one says the moment it first appears and the gesture that retires it. The list is the program's own table (the surface refuses to build if the two disagree), so a tip you saw is on it word for word. @@ -104,8 +104,6 @@ build if the two disagree), so a tip you saw is on it word for word. context window. Retired when a `/compact` finishes. - `/cost says what this conversation has spent` — once the conversation has spent about ten cents. Retired when you run `/cost`. -- `ctrl+. sees every task this project has run` — after the first task starts. Retired - when you open the task page, by `ctrl+.` or `/history`. - `/rewind takes back an earlier message` — after an answer of about 1,500 characters or more. Retired the first time a rewind lands. - `/files finds everything made for you` — after the first export writes a file. Retired @@ -166,8 +164,8 @@ build if the two disagree), so a tip you saw is on it word for word. `ctrl+b freezes the screen so you can read and copy from it` held for one build on 2026-09-22, and `ask for a picture, a voiceover, music or a video` before that.) -**Seven rows came off on 2026-09-22**, over two reads of the whole list, and **every one of -the commands they named still works** — only the tips about them are gone. +**Eight rows came off on 2026-09-22**, over three reads of the whole list, and **every one +of the commands they named still works** — only the tips about them are gone. - `alt+3 shows what this machine has spent, by the day`, `alt+1 to alt+7 jump straight to a place`, `/search finds anything ever said on this machine` and `/subharness lists the @@ -185,6 +183,9 @@ the commands they named still works** — only the tips about them are gone. file or folder" now and this one is gone. - `/attach lets you browse anywhere for files` was a second row about one command, which is one row too many. +- `ctrl+. sees every task this project has run` came off on the owner's word, the last of + the three reads. The chord still opens the task page, `/history` still opens it too, and + the *tasks* page still says so. Unless a line above says otherwise, a tip is true from the first minute on home and after the first exchange in a conversation. diff --git a/internal/tui3/chattip_test.go b/internal/tui3/chattip_test.go index e33868fdec..acb6c2578d 100644 --- a/internal/tui3/chattip_test.go +++ b/internal/tui3/chattip_test.go @@ -53,20 +53,20 @@ func tipRowOf(a *app, tip string) (int, []string) { func TestAConversationSaysItsTipOnTheKeysRow(t *testing.T) { a, _ := chatTipLab(t) b := &a.notices - startTask(t, a) - if b.current[slotHint] != "task-page-after-first-task" { - t.Fatalf("a task starting armed %q", b.current[slotHint]) + makeDeliverable(t, a) + if b.current[slotHint] != "files-after-first-deliverable" { + t.Fatalf("an export landing armed %q", b.current[slotHint]) } - if got := a.noticeHint(); got != taskPageTip { + if got := a.noticeHint(); got != deliverTip { t.Fatalf("the tip is not up the moment it arms: %q", got) } // ON THE FRAME: the foot, under the box, and NOT a row of its own over the // rule. - if got := plain(a.footHint(a.width)); !strings.Contains(got, taskPageTip) { + if got := plain(a.footHint(a.width)); !strings.Contains(got, deliverTip) { t.Fatalf("the keys row does not carry the tip: %q", got) } - y, rows := tipRowOf(a, taskPageTip) + y, rows := tipRowOf(a, deliverTip) if y < 0 { t.Fatalf("the tip is not on the frame:\n%s", strings.Join(rows, "\n")) } @@ -85,7 +85,7 @@ func TestAConversationSaysItsTipOnTheKeysRow(t *testing.T) { t.Fatalf("the tip drew over a box with a letter in it: %q", got) } drive(t, a, key("backspace")) - if got := a.noticeHint(); got != taskPageTip { + if got := a.noticeHint(); got != deliverTip { t.Fatalf("emptying the box did not give the row back: %q", got) } @@ -100,7 +100,7 @@ func TestAConversationSaysItsTipOnTheKeysRow(t *testing.T) { t.Fatalf("the tip drew under a place: %q", got) } a.leavePlace() - if got := a.noticeHint(); got != taskPageTip { + if got := a.noticeHint(); got != deliverTip { t.Fatalf("leaving the place did not give the row back: %q", got) } } @@ -108,7 +108,7 @@ func TestAConversationSaysItsTipOnTheKeysRow(t *testing.T) { // Silencing hints silences the conversation's row along with home's. func TestDisableHintsSilencesTheConversationRow(t *testing.T) { a, _ := chatTipLab(t) - startTask(t, a) + makeDeliverable(t, a) if a.noticeHint() == "" { t.Fatal("the tip is not up before the toggle") } @@ -116,7 +116,7 @@ func TestDisableHintsSilencesTheConversationRow(t *testing.T) { if got := a.noticeHint(); got != "" { t.Fatalf("a silenced profile still says %q in a conversation", got) } - if got := plain(a.footHint(a.width)); strings.Contains(got, taskPageTip) { + if got := plain(a.footHint(a.width)); strings.Contains(got, deliverTip) { t.Fatalf("a silenced profile still draws the tip on the keys row: %q", got) } } diff --git a/internal/tui3/hometip_test.go b/internal/tui3/hometip_test.go index 0176e07faa..357f580b52 100644 --- a/internal/tui3/hometip_test.go +++ b/internal/tui3/hometip_test.go @@ -248,11 +248,11 @@ func TestEveryTipIsOnTheManualPage(t *testing.T) { } } -// The cut was thirty, /project made it thirty-one, and two reads of the whole -// list by the owner took it to twenty-three. There is ONE set: every hint draws +// The cut was thirty, /project made it thirty-one, and three reads of the whole +// list by the owner took it to twenty-two. There is ONE set: every hint draws // on both boxes, a news row on neither, and a row filed under home's slot does // not build. -func TestTheTableIsTwentyThreeHintsAndEveryOneDrawsOnBothBoxes(t *testing.T) { +func TestTheTableIsTwentyTwoHintsAndEveryOneDrawsOnBothBoxes(t *testing.T) { hints := 0 for _, n := range notices { if n.slot != slotHint { @@ -266,8 +266,8 @@ func TestTheTableIsTwentyThreeHintsAndEveryOneDrawsOnBothBoxes(t *testing.T) { t.Errorf("hint %q draws in the transcript", n.id) } } - if hints != 23 { - t.Fatalf("the table holds %d hints, want 23 — the cut is deliberate, and the manual page counts them", hints) + if hints != 22 { + t.Fatalf("the table holds %d hints, want 22 — the cut is deliberate, and the manual page counts them", hints) } news := notice{id: "noted", slot: slotNote, armed: ready, text: "x"} if news.draws(slotHint) || news.draws(slotHome) || !news.draws(slotNote) { @@ -351,7 +351,7 @@ func TestTheCrossBlanksHomesRowAndSpendsNothingOfTheTipItPutAway(t *testing.T) { // press to land in, and nothing on that row is a door. func TestAConversationsTipRowCarriesNoCross(t *testing.T) { a, _ := sheetApp(t) - startTask(t, a) + makeDeliverable(t, a) if a.noticeHint() == "" { t.Fatal("the conversation says no tip to begin with") } @@ -361,7 +361,7 @@ func TestAConversationsTipRowCarriesNoCross(t *testing.T) { } // And the tip is on the keys row rather than on a row of its own with a // cross at the end of it. - if got := plain(a.footHint(a.width)); !strings.Contains(got, taskPageTip) { + if got := plain(a.footHint(a.width)); !strings.Contains(got, deliverTip) { t.Fatalf("the conversation's tip is not on the keys row: %q", got) } } diff --git a/internal/tui3/notice.go b/internal/tui3/notice.go index b14dfcad89..68a960399b 100644 --- a/internal/tui3/notice.go +++ b/internal/tui3/notice.go @@ -17,12 +17,12 @@ import ( // A surface learns you by what you have already done, and this file is where it // keeps what it has told you. Two kinds of thing live here at launch: // -// - EARNED HINTS. One dim line over the box — `ctrl+. sees every task this -// project has run` — drawn on the row directly above the rule over home's -// box, and on the same row above a conversation's box once the person has -// been idle there for a minute. A tip RETIRES FOR GOOD the first time the -// gesture it teaches is used (the task page opened), or after it has been -// shown [noticeShownDefault] times without being acted on. A hint that stays +// - EARNED HINTS. One dim line beside the box — `/files finds everything +// made for you` — on the row directly above the rule over home's box, and +// on the lowest rung of a conversation's keys row. A tip RETIRES FOR GOOD +// the first time the gesture it teaches is used (the files place opened), +// or after it has been shown [noticeShownDefault] times without being +// acted on. A hint that stays // up after you have learned the key is a cheatsheet, and a cheatsheet is // read once and never again (render.go's [app.hintWord] says the same about // static keys). @@ -31,18 +31,17 @@ import ( // The channel exists and is empty; a wave that ships something registers a // row with [notice.news] set and writes nothing else. // -// ONE TABLE, TWO BOXES. Until 2026-09-22 a hint row named which box it could -// draw beside and the conversation's foot ranked its rows by a priority number -// while home's row took turns. The owner ruled that there is ONE set of tips -// and that both boxes say them the same way: in the table's order, round and -// round, every tip that is true getting its turn — with one exception, that a -// tip which has JUST become true jumps the ring, so `/compact summarizes the -// conversation now` is said when the window crosses half and not forty minutes -// later ([noticeBoard.pick]). The two boxes keep two clocks, because home has -// no turns and a conversation has no visits: home's row moves on every visit -// and every [hintEvery] at rest; a conversation's row appears only once the -// person has been idle for [chatHintIdle], and then moves on every [hintEvery] -// while they stay idle ([app.noticeIdleBeat]). +// ONE TABLE, TWO BOXES, TWO RULES. Until 2026-09-22 a hint row named which box +// it could draw beside; the owner ruled that there is ONE set of tips, and the +// two boxes say them by rules of their own, because a conversation is a screen +// you sit in and home is a screen you pass through. A CONVERSATION RANKS: the +// first eligible row in the table's order takes the foot, so a tip that has +// just become true is said at once — `/compact summarizes the conversation +// now` when the window crosses half, not forty minutes later — and +// [noticeGap] keeps a busy first session from reading as a slideshow +// ([noticeBoard.rank]). HOME TAKES TURNS: every tip that is true gets one, the +// row moving on with every visit and every [hintEvery] at rest +// ([noticeBoard.rotate]). Both go through [noticeBoard.pick]. // // THE TABLE BELOW IS THE ONE PLACE A NOTICE IS WRITTEN DOWN, the way commands.go // is the one place a command is. [checkNotices] runs over it at init and fails @@ -72,9 +71,8 @@ import ( type noticeSlot uint8 const ( - // slotHint is the row directly above the rule over a conversation's box - // (view.go's [app.chrome] draws it on the foot's clearance), drawn only - // once the person has been idle for [chatHintIdle] and the frame is quiet + // slotHint is the lowest rung of a conversation's keys row at the foot + // (render.go's [app.footHint] draws it), taken whenever the frame is quiet // enough for a tip to be read over an idle box ([app.noticeHint]). slotHint noticeSlot = iota // slotNote is one calm transcript line through [feed.note]. It is reserved @@ -306,21 +304,21 @@ var ( // and the page say the same words — and notice_test.go holds the page to every // line here, so the table cannot say a thing the manual does not. // -// TWENTY-THREE ROWS, AND EVERY CUT WAS DELIBERATE. A survey of the surface on +// TWENTY-TWO ROWS, AND EVERY CUT WAS DELIBERATE. A survey of the surface on // 2026-09-21 turned up forty-eight lines worth saying; thirty of those shipped, // /project made thirty-one when it became a command of its own on 2026-09-22, -// and two reads of the whole list by the owner that same day took it to -// twenty-three. `alt+3`, `alt+1`–`alt+7`, `/search` and `/subharness` came off +// and three reads of the whole list by the owner that same day took it to +// twenty-two. `alt+3`, `alt+1`–`alt+7`, `/search` and `/subharness` came off // as rows the foot or the tab bar already teaches; the two lines about a // running answer became one; `/ask`'s came off ahead of the door it taught; // `/folder`'s came off because it was not true and /attach's line now covers -// both kinds; and the second /attach row was one row too many about one -// command. What was left out is +// both kinds; the second /attach row was one row too many about one command; +// and `ctrl+.` came off on the owner's word. What was left out is // what the foot already names — `alt+p`, `alt+e`, `alt+a`, `alt+k`, `/` — and // the second spelling of anything already here. `/ shows every command` was a // row until both feet started saying `/ commands` outright (footswap.go). var notices = []notice{ - // ── the seven that were here first ────────────────────────────────────── + // ── the rows this surface shipped with ───────────────────────────────── { id: "compact-at-half", slot: slotHint, armed: func(a *app) bool { @@ -336,12 +334,11 @@ var notices = []notice{ text: "/cost says what this conversation has spent", retire: eventCostShown, }, - { - id: "task-page-after-first-task", slot: slotHint, - armed: func(a *app) bool { return a.notices.seen[eventTaskStarted] }, - text: "ctrl+. sees every task this project has run", - retire: eventTaskPageOpened, - }, + // `ctrl+. sees every task this project has run` was here from the first + // seven rows until 2026-09-22, when the owner took it off. The chord, the + // page and `/history` are untouched, and [eventTaskStarted] and + // [eventTaskPageOpened] are still fired at their seams: nothing in the + // table waits on either one now, and a later row may. { id: "rewind-after-long-answer", slot: slotHint, armed: func(a *app) bool { return a.lastAnswerRunes() >= longAnswerRunes }, @@ -1037,10 +1034,10 @@ func (a *app) noticeLimit(id string) int { } // noticeRotate moves a row on to the next tip. It is asked on every visit to -// home ([app.showPage]), on home's beat once a tip has stood [hintEvery] at -// rest ([app.noticeHomeBeat]), and on the conversation's beat while the person -// stays quiet ([app.noticeIdleBeat]). Rotating is the one thing an event does -// not do to a slot, so it is its own seam. +// home ([app.showPage]) and on home's beat once a tip has stood [hintEvery] at +// rest ([app.noticeHomeBeat]) — HOME'S ROW ALONE, because a conversation's +// takes the first eligible row rather than a turn. Rotating is the one thing an +// event does not do to a slot, so it is its own seam. // // IT DOES NOT LIFT A CROSS. The row a person put away is put away until it // leaves the frame, and the beat that turns the ring every two minutes is diff --git a/internal/tui3/notice_test.go b/internal/tui3/notice_test.go index ab25e8bf5b..fe9536fd37 100644 --- a/internal/tui3/notice_test.go +++ b/internal/tui3/notice_test.go @@ -510,38 +510,46 @@ func startTask(t *testing.T, a *app) { drive(t, a, taskStartedMsg{kind: "single", id: "7", title: "port the parser"}) } -const taskPageTip = "ctrl+. sees every task this project has run" +// THE SUITE'S FIXTURE TIP, and the moment that arms it. These tests are about +// a tip's whole road — armed by something that happens mid-session, drawn, +// retired by a different gesture — so they need a row with an event on both +// ends. It was `ctrl+. sees every task this project has run` until 2026-09-22, +// when the owner took that row off the table. +const deliverTip = "/files finds everything made for you" + +// makeDeliverable is an export landing on disk, as the loop sees it: the first +// thing written for the person, which is what arms [deliverTip]. +func makeDeliverable(t *testing.T, a *app) { + t.Helper() + a.exportDone(exportedMsg{path: filepath.Join(t.TempDir(), "talk.md")}) +} // THE WHOLE ROAD. A hint arms on its moment, draws in the hint slot and only at // the lowest rung there, retires on the gesture it teaches, and is still // retired when the surface comes up again over the same profile. func TestAHintArmsDrawsLowestRetiresAndStaysRetired(t *testing.T) { a, dir := sheetApp(t) - // The gesture that retires the hint is the task page OPENING ON ROWS, and - // the row seeded below is dated from the fixture clock — so the surface goes - // on it too ([pinFixtureClock] states the law). - pinFixtureClock(a) if got := a.notices.current[slotHint]; got != "" { t.Fatalf("a fresh surface already holds hint %q", got) } - if strings.Contains(plain(frame(a)), taskPageTip) { - t.Fatal("the task page tip is up before any task has started") + if strings.Contains(plain(frame(a)), deliverTip) { + t.Fatal("the files tip is up before anything has been written") } - startTask(t, a) - if got := a.notices.current[slotHint]; got != "task-page-after-first-task" { - t.Fatalf("a task starting armed %q", got) + makeDeliverable(t, a) + if got := a.notices.current[slotHint]; got != "files-after-first-deliverable" { + t.Fatalf("an export landing armed %q", got) } // AND IT IS UP THE MOMENT IT ARMS, on the keys row at the foot: the // conversation's tip is on no clock (chattip_test.go holds the whole of // where it draws). - if got := a.noticeHint(); got != taskPageTip { + if got := a.noticeHint(); got != deliverTip { t.Fatalf("the tip row reads %q, want the tip", got) } - if got := plain(a.footHint(a.width)); !strings.Contains(got, taskPageTip) { + if got := plain(a.footHint(a.width)); !strings.Contains(got, deliverTip) { t.Fatalf("the keys row does not carry the tip: %q", got) } - if !strings.Contains(plain(frame(a)), taskPageTip) { + if !strings.Contains(plain(frame(a)), deliverTip) { t.Fatalf("the tip is not on the frame:\n%s", plain(frame(a))) } @@ -557,42 +565,38 @@ func TestAHintArmsDrawsLowestRetiresAndStaysRetired(t *testing.T) { t.Fatalf("a tip drew over a box with words in it: %q", got) } a.input.reset() - if got := a.noticeHint(); got != taskPageTip { + if got := a.noticeHint(); got != deliverTip { t.Fatalf("the tip did not come back over an empty box: %q", got) } - // THE GESTURE RETIRES IT: the task page actually opening. - a.comp.tasks = []session.TaskIndexEntry{pastTask("4", "port-the-parser", "Port the parser", time.Hour)} - if !openTaskPlaceWithRows(a) { - t.Fatal("the task page did not open") - } - a.closeTaskSheet() + // THE GESTURE RETIRES IT: /files actually reached for. + a.slash("/files") if got := a.notices.current[slotHint]; got != "" { t.Fatalf("the slot still holds %q after the gesture", got) } - if !a.notices.retired("task-page-after-first-task") { + if !a.notices.retired("files-after-first-deliverable") { t.Fatal("the gesture did not retire the hint") } - if strings.Contains(plain(frame(a)), taskPageTip) { + if strings.Contains(plain(frame(a)), deliverTip) { t.Fatal("the tip is still drawn after its gesture") } // Re-arming does nothing this session either. - startTask(t, a) - if got := a.notices.current[slotHint]; got == "task-page-after-first-task" { + makeDeliverable(t, a) + if got := a.notices.current[slotHint]; got == "files-after-first-deliverable" { t.Fatal("a retired hint came back in the same session") } // And it is on disk, beside config.json, so the next surface knows. ledger := loadNoticeLedger(filepath.Join(dir, noticeLedgerName)) - if !ledger.retired("task-page-after-first-task") { + if !ledger.retired("files-after-first-deliverable") { t.Fatalf("the ledger on disk does not have it retired: %+v", ledger) } again := noticeApp(t, dir) - startTask(t, again) - if got := again.notices.current[slotHint]; got == "task-page-after-first-task" { + makeDeliverable(t, again) + if got := again.notices.current[slotHint]; got == "files-after-first-deliverable" { t.Fatal("a retired hint came back after a restart") } - if strings.Contains(plain(frame(again)), taskPageTip) { + if strings.Contains(plain(frame(again)), deliverTip) { t.Fatal("the tip is drawn after a restart") } } @@ -730,8 +734,8 @@ func TestTheCompactHintFollowsTheContextReading(t *testing.T) { // lands at the next turn end, the way the mouse row's does. func TestTheHintsRowSilencesTheSlot(t *testing.T) { a, dir := sheetApp(t) - startTask(t, a) - if got := a.noticeHint(); got != taskPageTip { + makeDeliverable(t, a) + if got := a.noticeHint(); got != deliverTip { t.Fatalf("the tip row reads %q before the toggle", got) } @@ -754,12 +758,12 @@ func TestTheHintsRowSilencesTheSlot(t *testing.T) { if a.notices.enabled { t.Fatal("the turn end did not re-read the row") } - if got := a.noticeHint(); got == taskPageTip { + if got := a.noticeHint(); got == deliverTip { t.Fatal("a silenced slot still draws the tip") } // The next surface over this profile is quiet from the start. again := noticeApp(t, dir) - startTask(t, again) + makeDeliverable(t, again) if got := again.notices.current[slotHint]; got != "" { t.Fatalf("a silenced profile armed %q", got) } @@ -844,8 +848,8 @@ func TestABoardWithNoPathKeepsNoticesForTheSession(t *testing.T) { if a.notices.path != "" { t.Fatalf("the pinned board has a ledger at %q", a.notices.path) } - startTask(t, a) - if got := a.notices.current[slotHint]; got != "task-page-after-first-task" { + makeDeliverable(t, a) + if got := a.notices.current[slotHint]; got != "files-after-first-deliverable" { t.Fatalf("a session-only board armed %q", got) } if err := a.notices.ledger.write(""); err != nil { From 9532714a6363400f23e50671913af47c2b2c71a7 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Tue, 22 Sep 2026 22:47:38 -0400 Subject: [PATCH 27/39] docs: the change entry for the tip wave What a reader of this repository now believes wrongly: copy mode and /image existed, /folder chose the project, home had no tip row and no @ list, /manual printed pages without a model call, and a showing was any change of hands. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .../unreleased/1389-one-tip-list-two-boxes.md | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 docs/changes/unreleased/1389-one-tip-list-two-boxes.md diff --git a/docs/changes/unreleased/1389-one-tip-list-two-boxes.md b/docs/changes/unreleased/1389-one-tip-list-two-boxes.md new file mode 100644 index 0000000000..0e538a22ed --- /dev/null +++ b/docs/changes/unreleased/1389-one-tip-list-two-boxes.md @@ -0,0 +1,36 @@ +--- +kind: changed +title: one list of twenty-two tips, said two ways — home rotates them, a conversation ranks them +pr: 1389 +surface: [chat, docs] +invalidates: + - "Home never drew an earned tip: the picker refused the page outright. Home now has a tip row directly above the rule over its box, right-aligned, led by a bulb and closed by a cross a pointer can press." + - "A hint row named which box it could draw beside, and the conversation's foot ranked its rows by a per-row `priority` number. There is ONE table now and no priority column: the table's own order is the ranking, every row draws on both boxes, and a row filed under home's slot fails the build." + - "Copy mode existed: ctrl+b froze the viewport, v/a/y worked in it, and /copy was a command. All of it is deleted. ctrl+b is bound to nothing in a conversation and is the caret's left on home, /copy is an unknown word, and copying is the mouse — ctrl+s hands the pointer over and a sweep copies on release, through OSC 52. copymode.go is clipboard.go." + - "/image was a command that attached a picture. It is gone from the table, the dispatch, home's gate and the path completion; /attach already told a picture from a file by its name, and typing /image is answered as any unknown word is." + - "/folder did two different things depending on the screen it was typed on — gave a conversation a folder, or pinned the folder the next conversation opens in. The pin is /project now, which is home's alone: `/project <path>` takes the path and opens nothing, a bare /project opens the browser, and in a conversation it says which screen it lives on. /folder means one thing everywhere, and never moves the directory codeaf is standing in." + - "/attach took a file. It takes a file or a folder — a folder goes through the same seam /folder uses — and a bare /attach on home opens the browser aimed at the next conversation's folder." + - "Home's box had no @ completion, and the project sat on home's rule. The @ list works on home now (files and folders, never tasks), and the project moved to the right end of the keys row under the box, in a conversation as well as on home." + - "/manual printed the manual's pages as written with no model call. It is a turn now: the question goes to the model, told to answer from the manual tool and name the page. The as-written reading is still `codeaf manual` at the terminal." + - "The Workspace tab's row was `ui.hints` meaning shown. The row reads `disable hints`, off by default; the persisted key keeps its bytes and internal/config inverts once on the way in and out." + - "A showing was every visible change of hands of a tip row. On home a showing is now a tip that stood twenty seconds where it could be seen; in a conversation it is one session, counted when the slot takes the tip. A ledger written under the old rule is read once with the rows that rule spent forgiven, and the tips a gesture retired stay retired." + - "Two spaces over an empty box opened home. The surface stopped doing that on 2026-09-17; esc is the door, and five passages in the manual that still taught the old gesture now say so." +--- +There is one table of tips and there are two boxes, and the wave ends with the +two boxes saying them by rules of their own, because a conversation is a screen +you sit in and home is a screen you pass through. + +A CONVERSATION RANKS. The tip is the lowest rung of the keys row at the foot, +taken from the rest state and outranked by every state with keys of its own. +The first eligible row in the table's order wins, so a tip that has just become +true is said at once, and a gap of two turns keeps a busy first session from +reading as a slideshow. No clock, no cross. + +HOME ROTATES. The tip is the row over the rule, and every tip that is true gets +its turn — moving on with every visit and every two minutes at rest. The cross +means enough of these for now: the row goes blank and nothing takes its place +until home itself leaves the frame, and the tip put away is charged nothing. + +The list is twenty-two rows, from a survey of forty-eight lines worth saying +and three reads of the whole thing by the owner. Using a gesture on either box +retires its tip on both. From d2bdfdd1c66e9d4c60d8458d5cac6daef8efd65e Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Tue, 22 Sep 2026 23:11:17 -0400 Subject: [PATCH 28/39] chat: the ctrl+enter tip says a rule, the way /standing's row does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ctrl+enter sends your message as something to keep true` was the third row about a standing order and the only one still spelling it the old way — "keep true" is exactly what /standing's row was taken off for, since it made a rule and a memory look like one thing. The two rows retire on the same event, so they are one lesson told twice; they now tell it in the same words: /standing turns a sentence into a rule work must follow ctrl+enter makes your message a rule instead of a request Same id, same gesture. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- internal/manual/chat/hints-and-tips.md | 5 +++-- internal/tui3/notice.go | 9 ++++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/internal/manual/chat/hints-and-tips.md b/internal/manual/chat/hints-and-tips.md index 30bb6fbf2a..71609e885c 100644 --- a/internal/manual/chat/hints-and-tips.md +++ b/internal/manual/chat/hints-and-tips.md @@ -114,8 +114,9 @@ build if the two disagree), so a tip you saw is on it word for word. conversations. Retired when a standing order is made or the standing page opened. - `/task starts work you can walk away from` — after the first exchange. Retired when `/task` is typed, bare or with a brief. -- `ctrl+enter sends your message as something to keep true` — retired when a standing - order is made or the standing page opened. +- `ctrl+enter makes your message a rule instead of a request` — retired when a standing + order is made or the standing page opened. It teaches the same door as the `/standing` + row above and retires with it, so the two say a rule in the same words. - `/manual answers any question about codeaf` — retired when `/manual` is typed, bare or with a question. - `ctrl+shift+t reopens the last closed conversation tab` — retired the first time the chord is diff --git a/internal/tui3/notice.go b/internal/tui3/notice.go index 68a960399b..eeebdafd5e 100644 --- a/internal/tui3/notice.go +++ b/internal/tui3/notice.go @@ -385,9 +385,16 @@ var notices = []notice{ retire: eventTaskTyped, }, { + // THE THIRD ROW ABOUT A STANDING ORDER, and it says the same thing as + // the one above in the same words since 2026-09-22. It read `sends your + // message as something to keep true` — which is exactly the spelling + // `/standing`'s row had just been taken off, for teaching that a rule + // and a memory were one thing (see `remember-one-thing`). The two rows + // retire on the SAME event, so they are one lesson told twice, and + // telling it twice in two vocabularies is the way to teach neither. id: "standing-by-chord", slot: slotHint, armed: ready, - text: "ctrl+enter sends your message as something to keep true", + text: "ctrl+enter makes your message a rule instead of a request", retire: eventStandingOpened, }, { From 1fae72c33ef15ab9c2180a0bff36f425703bd045 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Tue, 22 Sep 2026 23:18:24 -0400 Subject: [PATCH 29/39] Rename the Home scheduled heading to standing --- .../unreleased/1388-restore-home-controls.md | 3 ++ internal/e2e/tuiwords_test.go | 2 +- internal/manual/chat/commands.md | 2 +- internal/manual/chat/home.md | 36 +++++++++---------- internal/manual/chat/keeping-an-eye.md | 2 +- internal/manual/chat/keys.md | 6 ++-- internal/manual/chat/places.md | 2 +- internal/tui3/homepanel_next.go | 9 +++-- 8 files changed, 32 insertions(+), 30 deletions(-) diff --git a/docs/changes/unreleased/1388-restore-home-controls.md b/docs/changes/unreleased/1388-restore-home-controls.md index 965095e6ae..0f02d7d6cf 100644 --- a/docs/changes/unreleased/1388-restore-home-controls.md +++ b/docs/changes/unreleased/1388-restore-home-controls.md @@ -6,9 +6,12 @@ surface: [chat, docs] invalidates: - "Escape navigated outward until Home and two spaces only typed text. Two spaces in an empty box open Home again; Escape closes places into the conversation, interrupts its running answer, and double-Escape opens inline rewind." - "Home showed only search results above the seam and /ask selected a Home exchange. The ask-here and start-a-new-conversation rows are back below the results; Enter defaults to a conversation, Up then Enter asks here, and /ask is removed." + - "Home called its standing-orders panel scheduled. The heading is standing again, matching the place it opens." --- Restore the two interactions changed in #1071 while keeping subsequent command-menu, effort-shortcut, question-row, compact-layout and waiting-message fixes. The manual and terminal test recipes follow the restored keys. Deterministic tmux coverage drives the built binary at three widths and checks both submission routes with a local endpoint. + +Rename Home’s `scheduled` heading back to `standing` to match the tab it opens. diff --git a/internal/e2e/tuiwords_test.go b/internal/e2e/tuiwords_test.go index f9ca5a97e7..f6fe793c58 100644 --- a/internal/e2e/tuiwords_test.go +++ b/internal/e2e/tuiwords_test.go @@ -183,7 +183,7 @@ var tuiWords = map[string]tuiWord{ why: "the day and the fortnight on home, and the third word of the four-place bar", }, "homePanelNext": { - screen: "scheduled", + screen: "standing", why: "every standing order this machine will act on — reminders, routines, watches, rules — soonest first", }, "homeRunningWhisper": { diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index c1b27c5f06..08e08d7639 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -1088,7 +1088,7 @@ always in one order: an unheaded list of open tabs followed by up to three dimme closed conversations, `needs you` (every question waiting on you, a digit answers the top one from anywhere), `projects` (folders, read-only), `tasks` (the last day's tasks, running or landed, newest first), `since you left` (what landed while you were -away), `spend` (today and the fortnight) and `scheduled` (standing orders, soonest first). +away), `spend` (today and the fortnight) and `standing` (standing orders, soonest first). Which column a panel stands in follows what it holds: the panels with rows fill the **field** at the left, and the **rail** at the right holds `projects` and `spend` at its top with the quiet panels under them. An empty panel keeps its heading and one dim line naming what diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index 9628839450..0bea1a34ce 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -34,7 +34,7 @@ answering one question you would ask walking up to a colleague's desk: Spark Fleet Ssh Audit · 2 hosts up, 1 not made apartments-minto-street.md - scheduled + standing the 6am repo watch ``` @@ -76,7 +76,7 @@ panels below them, which are in the rail only because they are quiet today. **The rank never moves, only the side.** Within the field and within the rail the order is always `sessions`, question rows, `projects`, `since you left`, `spend`, -`scheduled` — so two panels that both fill never swap places. +`standing` — so two panels that both fill never swap places. One column under 110 cells, where every panel is in that one order and there is no rail; two columns from 110; three from 170, where the rail is the third and the field fills the @@ -92,7 +92,7 @@ from home stands there as a card. Nothing else stands in it. | `sessions` | one of the fifteen most recent conversations | opens the conversation | `your recent conversations appear here` | | `since you left` | what landed while you were away | opens the record, the file or the place | `what watches and tasks did while the terminal was shut` | | `spend` | today, the fortnight, who it went to | nothing — its lines are read, never stood on or pressed; the heading opens the spend place | `every chat and task is priced here` | -| `scheduled` | a standing order — reminder, routine, watch or rule — soonest first | opens the standing place | `reminders, routines, watches and rules · "remind me at 6" or "every morning at 9"` | +| `standing` | a standing order — reminder, routine, watch or rule — soonest first | opens the standing place | `reminders, routines, watches and rules · "remind me at 6" or "every morning at 9"` | **An empty headed panel keeps its heading and that one dim line** — it names what arrives there and the one thing that puts it there, and it never says the panel is empty. On a narrow column @@ -549,17 +549,17 @@ folds it. One panel is open at a time; opening a second folds the first. An open taller than the window shows what fits and its line still counts the rest — `3 fewer · 40 more` — and names no place, because `enter` on it folds rather than opens. The way to those rows is the panel's **heading**: `sessions` opens sessions, `since you left` opens memory, -and `scheduled` opens standing. Conversations and extra question rows have no heading; +and `standing` opens standing. Conversations and extra question rows have no heading; `projects` opens nothing. The foot under a fold says which way it will go: `enter shows the rest`, then `enter folds them`. Opening lasts as long as the window; a relaunch starts folded. The fold wears no mark: home spends its two marks on the amber `?` and the one moving cell. **A tall terminal grows the panels**, once every panel has what it naturally shows: -additional question rows, `since you left` and `projects` to eight rows; `sessions` keeps at most fifteen recent conversations; `scheduled` from three to five. `spend` never grows. What is left over is air +additional question rows, `since you left` and `projects` to eight rows; `sessions` keeps at most fifteen recent conversations; `standing` from three to five. `spend` never grows. What is left over is air under the shorter column. -**A short terminal squeezes them in a fixed order**: `scheduled` gives way first, then `spend`, +**A short terminal squeezes them in a fixed order**: `standing` gives way first, then `spend`, then `since you left`, then `sessions`, then `projects`; the conversation list and additional question rows shrink last. A squeezed panel keeps its heading, the rows that fit and its `N more` line; only when every panel is down to that is a panel dropped — and the panels that are only @@ -578,7 +578,7 @@ panels, and `enter` again folds it. The box at the foot still searches every con the machine as you type — a project's name, a folder's name or a word from what a task came to all find them — whether or not a panel is drawing the row. -Every panel's fold works the same way: `N more` under `sessions`, `since you left`, `scheduled` +Every panel's fold works the same way: `N more` under `sessions`, `since you left`, `standing` and `projects` opens that panel. The places themselves — tasks, standing, spend — are on the tab bar and their slash commands, not behind the folds. @@ -629,14 +629,14 @@ cent — one reading of one file, wherever you are standing. words — `home tasks spend settings` — and `tab`, `alt+1` … `alt+4` walk them. Standing, memory and search open exactly as they did: -- **`/standing`** (or `/orders`), `alt+5`, or `enter` on a `scheduled` row; +- **`/standing`** (or `/orders`), `alt+5`, or `enter` on a `standing` row; - **`/memory`** (or `/memories`), `alt+6`, or `enter` on memory's line in `since you left`; - **`/search`**, `alt+7`, or the typed door on home's box. While you stand in one of the three, its word is drawn after the four so you can see where you are; `tab` from there goes to home. `alt+.` draws the map of all seven with their numbers. Home's own panels already summarise the three on the bar: `sessions` is a glimpse of -tasks, `spend` of spend, `scheduled` of standing. +tasks, `spend` of spend, `standing` of standing. ## Why did a dashboard open when I started codeaf — home greets you @@ -727,7 +727,7 @@ goes empty on the `start a new conversation` row, which is a chat that does not **One click is `enter`.** A click on a row opens it, and a click on a fold that names a place opens that place. A click on a panel's **heading** opens the place the heading names: `sessions` opens sessions, `since you left` opens memory, `spend` opens spend, and -`scheduled` opens standing. There is no `needs you` heading. The conversation list has no heading. The `projects` heading opens nothing +`standing` opens standing. There is no `needs you` heading. The conversation list has no heading. The `projects` heading opens nothing and stays dim. **A heading that opens somewhere underlines on mouse-over.** The pointer on a heading moves neither the cursor nor the marked heading. A click on a `/` command, `ask here` or the new-conversation row only selects it; @@ -1630,14 +1630,14 @@ three ways:** - **while it is asking you something**, it is a row of `needs you`, with the amber `?` and what it is asking under it; -- **while it is firing**, it is a row of `scheduled` like any other order — it is not a +- **while it is firing**, it is a row of `standing` like any other order — it is not a task and has no row on `sessions`. It is still the item — `ctrl+e` pauses it, `ctrl+x` stops it, and `alt+e` raises how hard it thinks; -- **while it is simply waiting for its time**, it is a row of `scheduled`, soonest first, with +- **while it is simply waiting for its time**, it is a row of `standing`, soonest first, with when it goes off at the right — `in 20h`, `mon 8:30`. `enter` on a question row opens the conversation that asked for it; on a `sessions` row it -opens the conversation; on a `scheduled` row it opens the standing place. +opens the conversation; on a `standing` row it opens the standing place. The `◦` mark itself belongs to the standing place and to a conversation's own lines — `◦ leave for the train · in 4m`. `∙` is a paused item there, and `◆` means the thing went off @@ -1683,7 +1683,7 @@ dialled to `max` still does not turn every check on the machine into a deep pass and an item nobody has dialled asks for nothing. **`alt+e` on an item's row is how you raise the one that deserves it.** Put the cursor on -the standing item — on home at rest it has a row under `scheduled`, or under `needs you` +the standing item — on home at rest it has a row under `standing`, or under `needs you` while it is asking you something — and press it: the rung climbs one step each press — `low`, `medium`, `high`, `xhigh`, `max`, then back to `low` — and home says `thinking high · <your words>` at the foot. The item's sheet on a phone then carries a dim @@ -2095,7 +2095,7 @@ have. ## What is scheduled on home — reminders, routines, watches and rules, what is next up and when -**The `scheduled` panel, last of the seven**: every standing order this machine will act +**The `standing` panel, last of the seven**: every standing order this machine will act on, from every project, **soonest first**, with the rules that simply hold at the end. Four kinds of order stand on it — a **reminder** (`remind me at 6`), a **routine** (`every morning at nine`), a **watch** (`tell me when CI goes red`, `when go.sum changes`) and a @@ -2103,7 +2103,7 @@ morning at nine`), a **watch** (`tell me when CI goes red`, `when go.sum changes and **nothing at its right**: ``` - scheduled + standing the 6am repo watch top movers before the open tell me when CI goes red on master @@ -2126,7 +2126,7 @@ a date beyond it (`21 sep 9:00am`), and `now` once they have arrived. A routine never fired has no `last:`; a watch that has never looked has no `last looked`. An order in the middle of a pass says what the pass is doing instead of its clock. -**An order stopped on you is not on `scheduled`** — it is a row of `needs you`, with its +**An order stopped on you is not on `standing`** — it is a row of `needs you`, with its question, and comes back here the moment you answer. Paused, stopped and retired orders are not on it either. @@ -2135,7 +2135,7 @@ door into the standing place**, where the orders are kept and changed. With nothing standing it keeps its heading and `reminders, routines, watches and rules · "remind me at 6" or "every morning at 9"` — the -words that set one up. On a short terminal `scheduled` is the first panel to give way. +words that set one up. On a short terminal `standing` is the first panel to give way. ## How much did today cost — the spend panel on home diff --git a/internal/manual/chat/keeping-an-eye.md b/internal/manual/chat/keeping-an-eye.md index ec63043794..321c14a968 100644 --- a/internal/manual/chat/keeping-an-eye.md +++ b/internal/manual/chat/keeping-an-eye.md @@ -730,7 +730,7 @@ everything you set up from it: **Home and the standing place both work over a connection**, and both are about the far machine: home's panels are that machine's — its questions, its running work, its -`scheduled` — and the +`standing` — and the standing place lists both what stands on this conversation and what stands anywhere else on that machine. `p` and `s` write to the far machine's store and the refusal, if the store refuses, is that store's own. The count at the foot of the task column — and the diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index f9cd0cafa6..f81764857a 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -2213,7 +2213,7 @@ what each holds), and its keys are a small grammar: | `↑` / `↓` (`ctrl+p` / `ctrl+n`) | walk the field, from one panel into the next, and stop at both ends: `↑` off the top row stays there and does not climb onto the tab bar (reach the bar with a click, `tab`, or a place's chord) | | `←` / `→` | cross to the next column, onto the row nearest the one you left — only into a column with a row to stand on | | a digit, or a question's own key | answers **the one row of `needs you` that is drawing its answers**, from anywhere on home, with no cursor move — the row under the cursor when it can take one, the top answerable row otherwise. A question's chips are `1 allow once 2 always 3 deny`; a landing in `unread` offers `1 accept 2 not right`, its own `[a]`/`[n]` being letters and letters always type on home | -| `enter` | acts on the row under the cursor: a conversation opens, a project row starts a new chat in that folder, a `since you left` line opens its record, file or place, a `scheduled` row opens standing, a fold line opens or shuts its panel. `spend`'s lines are not stops, so the cursor never reaches them | +| `enter` | acts on the row under the cursor: a conversation opens, a project row starts a new chat in that folder, a `since you left` line opens its record, file or place, a `standing` row opens standing, a fold line opens or shuts its panel. `spend`'s lines are not stops, so the cursor never reaches them | | `pgup` / `pgdown` | jump a screenful | | `tab` | **the next place** on the bar | | `esc` | clears the box if anything is in it, and closes home otherwise | @@ -2248,7 +2248,7 @@ ones. Enter or `ctrl+e` on a closed row reopens it. **`ctrl+o`** opens its folder and **`ctrl+y`** copies its path. **`ctrl+t`** on a conversation's row still starts a new one in that row's folder, though `enter` on a row of the `projects` panel is the way home offers now. On a standing item's row — in `needs you` while it asks, in -`scheduled` otherwise, firing or not — **`ctrl+e` pauses** it, **`ctrl+x` stops it for good**, and +`standing` otherwise, firing or not — **`ctrl+e` pauses** it, **`ctrl+x` stops it for good**, and **`alt+e` raises how hard that item thinks** one rung. Each chord acts on the row under your pointer when there is one, the cursor's row otherwise. The machine's own default is not on this chord — it is the `thinking` row of `/settings`, and *alt+e — how hard the @@ -3025,7 +3025,7 @@ and it moves the rung of **the thing you are standing on**. One chord, three sco | The message box, typing or empty | **This conversation's** rung — the one on the legend above the box, beside the model, see *The thinking chip above the message box* | | The task roster holds the keyboard (`alt+t`) and the cursor is on a task | That task's rung | | You are inside a task's page | That task's rung | -| Home, with the cursor on a standing item's row — in `needs you` or `scheduled` | That item's rung | +| Home, with the cursor on a standing item's row — in `needs you` or `standing` | That item's rung | Everywhere else it does nothing at all. A conversation row on home is deliberately not on the list: a conversation's rung belongs to the window that conversation is open in, where diff --git a/internal/manual/chat/places.md b/internal/manual/chat/places.md index 328ee5b15e..1ec78e5ad2 100644 --- a/internal/manual/chat/places.md +++ b/internal/manual/chat/places.md @@ -449,7 +449,7 @@ being held down — it only reports what arrived. The first place, and the one codeaf opens on. Everything on this machine, from every project, in one, two or three columns — one `sessions` list of the fifteen most recent -conversations, then question rows, `projects`, `since you left`, `spend`, and `scheduled`. +conversations, then question rows, `projects`, `since you left`, `spend`, and `standing`. Open tabs and saved history share that list, with closed conversations dimmed. Which column a panel stands in follows what it holds: every panel with rows is in the **field** at the left, and the **rail** at the right holds `projects` and `spend` at its top and, under diff --git a/internal/tui3/homepanel_next.go b/internal/tui3/homepanel_next.go index 809a0ad876..4a78220c14 100644 --- a/internal/tui3/homepanel_next.go +++ b/internal/tui3/homepanel_next.go @@ -14,10 +14,9 @@ import ( // whole machine. Every row opens the standing place, where the orders are kept. type nextPanel struct{ homePanelBase } -// homeScheduledWord is the panel's heading: one word, no explainer (owner, -// 2026-09-15; it was `next up · reminders & routines`, which named two of the -// four kinds of order that stand on it). -const homeScheduledWord = "scheduled" +// homeScheduledWord matches the standing place the heading opens. The panel +// includes reminders, routines, watches and rules, not only scheduled work. +const homeScheduledWord = "standing" func (nextPanel) rows(in *homeGridInput) homePanelRows { views := nextActive(in) @@ -129,7 +128,7 @@ func nextUpSaid(view StandingItemView, now time.Time) string { return rowClauses(nextUpKindWord(item), words, next, last) } -// The fixed words of a `scheduled` row's sentence, spelled once. +// The fixed words of a `standing` row's sentence, spelled once. const ( nextUpGoesOffWord = "goes off " nextUpNextWord = "next " From 8a33f03a0d531dc946dcfe5c3071f816cbf18d1e Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Wed, 23 Sep 2026 00:30:24 -0400 Subject: [PATCH 30/39] =?UTF-8?q?Revert=20"chat:=20copy=20mode=20is=20gone?= =?UTF-8?q?"=20=E2=80=94=20the=20feature=20stays,=20only=20its=20tip=20goe?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner's word, 2026-09-23: "we don't need a hint, but don't remove the feature." So b91a39103 is reverted whole. ctrl+b freezes the viewport again, v/a/y and y-on-a-mark work in it, /copy opens it, the status word and the mode's own keys row are back, and copymode.go has its name back. THE TIP DOES NOT COME BACK. `ctrl+b freezes the screen so you can read and copy from it` stays off the table, /autonomy keeps the seat it took, and the table stays at twenty-two rows. The row in notice.go that explains that seat now says so outright, because it was the one place a reader could conclude the feature had gone with its tip. Three things the revert could not know about, because they landed after it on this branch: - #1384's working logo. It taught both freeze doors to lay the page out after copy mode owns it, so the animated row and its private blank never become frozen transcript. That edit went into copymode.go and room.go, both of which the removal had deleted, so it is applied by hand here — along with its two tests and the room freeze's transient-row arithmetic. - The guards. worklogo.go asks `!a.copy.on` on both visibility rules again, and rewindReady counts a frozen page among the states that refuse to arm. - #1388's esc. The revert wanted background.go and keys.md to say `ctrl+c` interrupts; esc does. Both say `ctrl+b is copy mode and esc interrupts`, which is what #1388 itself wrote. The manual: the pages the removal edited are reverted with it, the working-indicator page says the logo steps aside in copy mode again, the rewind page counts copy mode among the states that refuse to arm, and the hints page notes that the feature works while its tip does not exist. The three probes about copy mode now ask about it working rather than about it being gone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .../unreleased/1389-one-tip-list-two-boxes.md | 2 +- internal/manual/chat/commands.md | 24 +- internal/manual/chat/hints-and-tips.md | 4 +- internal/manual/chat/home.md | 4 +- internal/manual/chat/keys.md | 127 +++-- internal/manual/chat/screen.md | 27 +- internal/manual/chat/sessions-and-rewind.md | 8 +- internal/manual/chat/starting-codeaf.md | 2 +- internal/manual/chat/tasks.md | 7 +- internal/manual/chat/what-i-can-do.md | 2 +- internal/manual/chat/working-indicator.md | 12 +- internal/manual/chat_test.go | 7 +- internal/tui3/app.go | 43 +- internal/tui3/background.go | 4 +- internal/tui3/bargein.go | 2 +- internal/tui3/bottomchrome_test.go | 22 + internal/tui3/bundle_test.go | 229 +++++++- internal/tui3/clipboard.go | 195 ------- internal/tui3/commands.go | 18 +- internal/tui3/copymode.go | 512 ++++++++++++++++++ internal/tui3/detach.go | 7 +- internal/tui3/dragspan_test.go | 2 +- internal/tui3/foot.go | 8 +- internal/tui3/gutter_test.go | 2 +- internal/tui3/home.go | 2 +- internal/tui3/homeslash.go | 2 +- internal/tui3/hop.go | 2 +- internal/tui3/hover.go | 4 +- internal/tui3/input.go | 23 +- internal/tui3/jobpage.go | 2 +- internal/tui3/jumpchip.go | 15 +- internal/tui3/markdownwrap_test.go | 42 ++ internal/tui3/moneydoor.go | 2 +- internal/tui3/notice.go | 19 +- internal/tui3/notice_test.go | 7 + internal/tui3/payload.go | 2 +- internal/tui3/payload_test.go | 6 +- internal/tui3/place_sessions.go | 2 +- internal/tui3/projectseam.go | 2 +- internal/tui3/question.go | 2 +- internal/tui3/render.go | 14 +- internal/tui3/rewind.go | 4 +- internal/tui3/rewindsheet.go | 2 +- internal/tui3/rewindsheet_test.go | 1 + internal/tui3/room.go | 57 +- internal/tui3/roomscroll_test.go | 51 ++ internal/tui3/roomstatus_test.go | 5 + internal/tui3/slotfilter_test.go | 18 +- internal/tui3/spellout.go | 2 +- internal/tui3/spellout_test.go | 11 +- internal/tui3/steer.go | 4 +- internal/tui3/steer_test.go | 27 + internal/tui3/stop.go | 4 +- internal/tui3/task.go | 2 +- internal/tui3/taskstrip.go | 4 +- internal/tui3/taskview.go | 4 +- internal/tui3/tui3_test.go | 2 +- internal/tui3/view.go | 3 + internal/tui3/worklogo.go | 4 +- internal/tui3/worklogo_test.go | 94 +++- 60 files changed, 1350 insertions(+), 367 deletions(-) delete mode 100644 internal/tui3/clipboard.go create mode 100644 internal/tui3/copymode.go diff --git a/docs/changes/unreleased/1389-one-tip-list-two-boxes.md b/docs/changes/unreleased/1389-one-tip-list-two-boxes.md index 442e1b4d1f..91446090c0 100644 --- a/docs/changes/unreleased/1389-one-tip-list-two-boxes.md +++ b/docs/changes/unreleased/1389-one-tip-list-two-boxes.md @@ -6,7 +6,7 @@ surface: [chat, docs] invalidates: - "Home never drew an earned tip: the picker refused the page outright. Home now has a tip row directly above the rule over its box, right-aligned, led by a bulb and closed by a cross a pointer can press." - "A hint row named which box it could draw beside, and the conversation's foot ranked its rows by a per-row `priority` number. There is ONE table now and no priority column: the table's own order is the ranking, every row draws on both boxes, and a row filed under home's slot fails the build." - - "Copy mode existed: ctrl+b froze the viewport, v/a/y worked in it, and /copy was a command. All of it is deleted. ctrl+b is bound to nothing in a conversation and is the caret's left on home, /copy is an unknown word, and copying is the mouse — ctrl+s hands the pointer over and a sweep copies on release, through OSC 52. copymode.go is clipboard.go." + - "Copy mode had a tip on the list, `ctrl+b freezes the screen so you can read and copy from it`. It has none. THE FEATURE IS UNTOUCHED — ctrl+b freezes the viewport, v/a/y work in it, /copy opens it, and the mouse copies alongside it through OSC 52 — and only the row teaching it came off. This branch deleted copy mode for one day on 2026-09-22 and put it back on 2026-09-23 at the owner's word, so a memory of it being gone is a memory of that day." - "/image was a command that attached a picture. It is gone from the table, the dispatch, home's gate and the path completion; /attach already told a picture from a file by its name, and typing /image is answered as any unknown word is." - "/folder did two different things depending on the screen it was typed on — gave a conversation a folder, or pinned the folder the next conversation opens in. The pin is /project now, which is home's alone: `/project <path>` takes the path and opens nothing, a bare /project opens the browser, and in a conversation it says which screen it lives on. /folder means one thing everywhere, and never moves the directory codeaf is standing in." - "/attach took a file. It takes a file or a folder — a folder goes through the same seam /folder uses — and a bare /attach on home opens the browser aimed at the next conversation's folder." diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index 0f06ddadf5..068fa8ca4c 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -45,7 +45,7 @@ A partial path such as `/tmp` is still indistinguishable from an unknown command it shows `no commands match` until another slash or path punctuation makes the intent clear. Pasting a complete path never needs those intermediate states. -Panels such as settings, the model picker and resume keep their own keyboard +Panels such as settings, the model picker, resume and copy mode keep their own keyboard handling; typing `/` there does not open this composer list. ## Slash commands are drawn as chips @@ -202,6 +202,7 @@ Canonical word, the other words it answers to, its argument form, and what it do | `/debug` | — | — | keeps the full record of **this conversation** from here on, and says which folder it goes to | | `/update` | `/upgrade` | — | installs the newest stable release and restarts this conversation on it | | `/update` | `/upgrade` | `<stable\|rc\|dev\|staging\|tag>` | installs that channel's newest release or one exact tag, then restarts this conversation on it | +| `/copy` | — | — | enters copy mode (also ctrl+b) | | `/select` | — | — | hands the pointer back to the terminal (also ctrl+s) | | `/export` | `/save` | — | writes the whole conversation to a file | | `/export` | `/save` | `<path>` | …and writes it there; tab completes the path | @@ -378,17 +379,18 @@ second `enter` on that same point does the rewind, `esc` clears the search and t closes the page. The head reads `⟲ rewind — pick where the conversation goes back to` and the foot reads `⟲ drops 2 turns — everything below the pick is let go`. -**Be warned: `/rewind` silently does nothing in five states.** No message, no page, +**Be warned: `/rewind` silently does nothing in six states.** No message, no page, nothing at all happens when: - the rewind timeline is already open, - the inline rewind mode is already on, +- copy mode is on, - a task room is open, - the settings panel is open, - the task rail is full. Each of those already owns the frame or the row the rewind needs, so the command is -dropped rather than half-drawn. If `/rewind` seems to do nothing, one of those five is why. +dropped rather than half-drawn. If `/rewind` seems to do nothing, one of those six is why. With no rewind points, or no agent that can rewind, it does answer, exactly: @@ -396,13 +398,17 @@ With no rewind points, or no agent that can rewind, it does answer, exactly: nothing to rewind ``` -## /copy — what happened to /copy, there is no /copy any more, copy mode was removed, how do I copy from the conversation +## /copy — read the conversation back and copy from it -`/copy` is not a command. It entered copy mode — the frozen conversation `ctrl+b` also -opened, read with ↑↓, `v`, `a` and `y` — until 2026-09-22, when copy mode was removed -whole. Typing it now answers `there is no command called /copy · / lists them`. To copy -text out of the conversation, drag across it with the mouse, or press `ctrl+s` and let -your terminal select (the keys page, *Selecting text with your mouse*). +`/copy` freezes the visible conversation and enters copy mode. It is the same thing +ctrl+b does. In copy mode ↑↓ move, `v` marks, `a` takes the block, `y` yanks. + +**`/copy` silently does nothing in two states**, with no message either way: + +- copy mode is already on, +- there are no visible rows to freeze. + +If you type `/copy` and the screen does not change, one of those two is why. ## /project — set the project on home, which folder will my next conversation open in, change the project diff --git a/internal/manual/chat/hints-and-tips.md b/internal/manual/chat/hints-and-tips.md index 8e509a9339..9870d07b66 100644 --- a/internal/manual/chat/hints-and-tips.md +++ b/internal/manual/chat/hints-and-tips.md @@ -169,7 +169,9 @@ build if the two disagree), so a tip you saw is on it word for word. - `/autonomy sets how questions are handled while you are away` — after the first exchange. Retired when `/autonomy` is typed, bare or with a rule. (It took the seat `ctrl+b freezes the screen so you can read and copy from it` held for one build on - 2026-09-22, and `ask for a picture, a voiceover, music or a video` before that.) + 2026-09-22, and `ask for a picture, a voiceover, music or a video` before that. + **Copy mode itself still works** — `ctrl+b`, `/copy`, the whole frozen viewport — it + just has no tip on this list any more.) **Eight rows came off on 2026-09-22**, over three reads of the whole list, and **every one of the commands they named still works** — only the tips about them are gone. diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index 65e7bb0b43..2b800da767 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -1094,7 +1094,7 @@ it redraws it from its own transcript, and these come back with it: - the transcript, the task column, the meters, the model, the title and any card still waiting for an answer — all of which are read back from the conversation itself. -**These are forgotten:** a rewind you were part way through, the settings panel, +**These are forgotten:** copy mode, a rewind you were part way through, the settings panel, the model picker, `/history`, the deliverables shelf, a task column focus. Each is something you are in the *middle* of, or a door onto something the whole terminal shares. @@ -1286,7 +1286,7 @@ one behind your back. This is every fate, in the words the drop-up draws them in | **`opens the page`** | `/settings` `/set` `/config` · `/home` · `/search` · `/spend` · `/standing` · `/memory` `/memories` · `/history` · `/task` (bare) | A place replaces a place, exactly as before. | | **`this list is /resume`** | `/resume` `/sessions` | Says `this list is /resume · enter opens a row` — home *is* that list. | | **`onto home's tray`** | `/attach <path>` | The file — or picture — rides on home's own tray into the conversation you open next. Home says `attached · notes.md · rides with the next conversation`. A bare `/attach` opens the browser aimed at the next conversation's folder, and a file chosen there lands on the tray. | -| **`opens a conversation here first`** | `/files` · `/folder` `/place` `/dir` · `/manual` · `/crew` (bare) · `/permissions` `/perms` · `/connect` · `/harness` · `/subharness` · `/select` · `/rewind` `/undo` `/back` · `/compact` · `/export` `/save` · `/standing <words>` · `/task <brief>` | Opens a conversation at the target — the folder and model on the rule above the box — then runs there. Home closes, exactly as `enter` closes it. `/manual` is on this road since 2026-09-22: it is a question put to the model, so it needs a conversation to be asked in. `/folder` joined it the same day — it gives THIS conversation a folder, and home has no this; the pin it used to be here is `/project`. | +| **`opens a conversation here first`** | `/files` · `/folder` `/place` `/dir` · `/manual` · `/crew` (bare) · `/permissions` `/perms` · `/connect` · `/harness` · `/subharness` · `/copy` · `/select` · `/rewind` `/undo` `/back` · `/compact` · `/export` `/save` · `/standing <words>` · `/task <brief>` | Opens a conversation at the target — the folder and model on the rule above the box — then runs there. Home closes, exactly as `enter` closes it. `/manual` is on this road since 2026-09-22: it is a question put to the model, so it needs a conversation to be asked in. `/folder` joined it the same day — it gives THIS conversation a folder, and home has no this; the pin it used to be here is `/project`. | | **`answers here`** | `/help` · `/status` · `/cost` · `/cache` · `/budget` · `/crew <preset>` · `/debug` · `/stop` · `/remember` · `/forget` · a word nobody defined | Answers with a note, and the first line of that note is put on home's own line under the box. `there is no command called /pricing · / lists them` is now something you can read. | | **`runs on the conversation behind home`** | `/land` · `/land <folder>` · `/workspace <path>` | Acts on the conversation this window is holding behind the screen — not on the one `enter` would open — and its answer is echoed onto home's line. | | **`a fresh conversation behind home`** | `/new` `/clear` `/clean` `/reset` | Replaces the conversation behind the screen and says `started a fresh conversation behind home`. It is not the same act as `enter`, which opens a conversation at the target. | diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index 09ee898282..61b19b9736 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -589,7 +589,7 @@ key arrives as ordinary `enter` and the message steers instead. | Chord | What it does | |---|---| | `ctrl+o` | Selected landed card: open its output. Selected proposal: open its brief. Inside a task's page: open or fold the long instruction at the top. Otherwise: open or fold the live caption's tool rows; before a live caption exists, open or fold the `N earlier tool calls` fallback. It never opens a `▸ worked` chip — that is `ctrl+e` | -| `ctrl+b` | Nothing in a conversation — copy mode was removed on 2026-09-22. On home it moves the caret one cell left | +| `ctrl+b` | Enter copy mode — freeze the view so you can read and copy. Not on home, where it moves the caret | | `ctrl+s` | Hand the pointer to your terminal so you can drag-select. Toggles; any other key takes it back | | `ctrl+,` | Open the settings panel | | `alt+e` | Walk this conversation's thinking rung one step: auto → low → medium → high → xhigh → max, and back to auto. Works with a sentence half typed. On home and every other place it walks the rung of the **next** conversation instead — the effort word after the model’s colon on home’s seam | @@ -934,6 +934,9 @@ the brackets can submit, interrupt, or answer a question. `ctrl+c` is the one exception and still works — it leaves codeaf without closing the bracket. A bracket that goes quiet for 2 seconds is treated as abandoned, flushed, and the keyboard handed back. +A paste while copy mode is up is **declined** — nothing happens, and your clipboard +still holds the text. + ## Make my prompt better — spell it out with `ctrl+r` Type what you want and press **`ctrl+r`**. codeaf reads the sentence sitting in the box @@ -2456,7 +2459,8 @@ back (*The empty screen* page). **With a room open:** `esc` leaves the room, though a history recall walk is cancelled first · `enter` steers the node (see *What steering a task looks like on its -page* below) · `pgup`/`pgdown` page · `up`/`down` walk your history, +page* below) · `ctrl+b` freezes the room's own rows for +copying, not the conversation's · `pgup`/`pgdown` page · `up`/`down` walk your history, and scroll the page one row only when there is no history to walk. `left` is deliberately **not** taken here — it falls through to the message box's back-navigation. @@ -2609,7 +2613,7 @@ and failing, and its letter is absent with it. **They are held to the same rule `x` is.** The card must be the **selected** one — walk to it with `↑`/`↓`, which steps through tool calls, proposals and landed cards — and the -message box must be **empty**, with no panel or picker up. A letter typed into a +message box must be **empty**, with no panel, picker or copy mode up. A letter typed into a sentence stays a letter, always. Once answered the letters go away and the receipt every question leaves takes their place, @@ -2712,7 +2716,8 @@ terminals open one and what is deliberately not linked. **A click on empty space does nothing, anywhere** — there is no empty-space gesture on this surface, and that includes inside a room: a press on a blank row of a task's page is not the way out and never closes it. The way out of a room is `esc`, `←`, or a press -on the pinned header that names them. +on the pinned header that names them. **A click in copy mode acts on nothing**, because +the rows there are a frozen snapshot. **Hover** lights whatever the pointer is on, at the size of the thing rather than the size of its row: a row that is one target — a tool call, a roster row, a parked message, a @@ -2721,14 +2726,14 @@ line — a strip chip, a picture on the tray, one answer of a card, a task refer reply — lights only its own cells, leaving its neighbours dark. Anything that answers to nothing does not react. On home it does one thing more: the preview on the right becomes the row you are pointing at, and returns to the cursor's row when you point somewhere else -(see the home page). There is no hover on the linear/screen-reader tier, or in the phone -tool sheet. The screen page says what lights, under "When a row brightens +(see the home page). There is no hover in copy mode, on the linear/screen-reader tier, or +in the phone tool sheet. The screen page says what lights, under "When a row brightens under the pointer". ## Scrolling The wheel moves three rows per notch, on whichever surface owns the frame. It is -routed to the settings panel, then the task page, then home, then the +routed to copy mode, then the settings panel, then the task page, then home, then the rewind timeline, then the status deck, then the phone tool sheet, then the fullscreen roster, then **the task column** when the pointer is over it, then an open room, and otherwise the conversation. @@ -2753,8 +2758,8 @@ away from the live edge. It is drawn into the first row of the frame's existing breathing gap, so it never takes a row of its own; on a window too short to have a gap it is not drawn at all, though `ctrl+l` still works. It is dim normally and accent-coloured under the pointer. Clicking it, or pressing `ctrl+l`, rejoins the live -edge — the room's edge if a room is open. It is not shown while a room or the -fullscreen roster is up. +edge — the room's edge if a room is open. It is not shown while copy mode, a room, or +the fullscreen roster is up. ## Selecting text with your mouse — drag to copy, select a word with the mouse, why did copying take the whole line instead of the words I dragged over @@ -2762,8 +2767,8 @@ fullscreen roster is up. down, and the cells between them highlight — from where you pressed to the end of that line, every line between in full, and the last line up to where you are, exactly as your terminal would select it. The moment you release, that text is **on your -clipboard** — stripped of colours and the drawn left rails, so a paste carries only -the words. There is nothing further to press: no ctrl+c, no key at all — +clipboard** — stripped of colours and the drawn left rails, exactly as copy mode +strips a yank. There is nothing further to press: no ctrl+c, no key at all — releasing the button IS the copy. The highlight stays lit for the few seconds the status line says what landed — `copied · 14 chars` for a span inside one line, `copied · 3 lines` across several — so you can see exactly what you got. The write @@ -2794,8 +2799,8 @@ place. The one difference is that a selection there is live — you can type ove so it stays lit until you move the caret rather than fading with the status line. See "how do I select text in the message box" above. -The sweep is drawn in **the selection background** — the strongest of the three this -screen draws, a shade above the one under the pointer. It is the same +The sweep is drawn in **the same background copy mode's selection wears** — the strongest +of the three this screen draws, a shade above the one under the pointer. It is the same claim ("these rows are what a copy would take"), so it is the same paint; it used to be drawn at the pointer's own quieter step, which said a sweep in progress was a shadow rather than a selection. @@ -2871,7 +2876,8 @@ your mouse" above. **There is nothing under the pointer.** A click on empty space does nothing anywhere on this surface, including the gap between two words of the tab bar and the blank rows of a -task's page. +task's page. A click in copy mode acts on nothing at all, because those rows are a frozen +snapshot. **The terminal is too narrow for the word you are aiming at.** The tab bar gives up words as the frame narrows, and at its narrowest it carries only the place you are standing in — @@ -2881,23 +2887,79 @@ and `alt+1`…`alt+7` still go everywhere. **A file path is your terminal's click, not codeaf's** — usually **cmd+click** (ctrl+click on Linux). If a plain click on a path does nothing, that is why. -## Copy mode is gone — ctrl+b does nothing, there is no /copy, how do I copy text out of the conversation +## Copy mode: taking text out of the conversation — ctrl+b, freeze the screen, esc to leave, and ctrl+b on home does nothing of the kind + +`ctrl+b` freezes the view and hands the keyboard to a reader, so you can pull text out +of a surface that runs in the alternate screen where your terminal's own selection is +gone. The `/copy` command does the same. Inside a room, `ctrl+b` freezes **the room's +rows** rather than the conversation's. + +**On home `ctrl+b` is not copy mode.** It moves the caret in home's box one cell to the +left, as `←` does, and home's rows are never frozen. Nothing can be copied off the home +screen this way: a title or a path on home is on a row `enter` opens, and inside that +conversation the rows can be frozen. (For one build on 2026-09-22 the key froze home's +own screen; it was taken out the same day.) + +Freezing looks like nothing when nothing is moving — the rows stay where they are on +purpose. What tells you the freeze is on is the keys row, which reads exactly +`v select · a block · y yank · esc`, the highlighted cursor row, and the status word +`COPY`. `esc` always leaves. + +| Chord | What it does | +|---|---| +| `up`/`k`, `down`/`j` | Move | +| `pgup`, `pgdown` | Page | +| `home`, `end` | Jump to top or bottom | +| `v` | Drop or lift the mark | +| `a` | Take the block under the cursor | +| `y` | Yank the selection | +| `esc`, `ctrl+b`, `q` | Leave | + +Copy mode takes **every other key too**, and does nothing with them. + +`a` asks the narrower question first — a run of fenced **code** rows. Press `a` again +to widen to the whole answer around it. A blank row belongs to nothing, and `a` there +does nothing. + +`y` **stays** in copy mode and lifts the mark, so a second `y` cannot copy the same +span by accident. + +The status line reads exactly `COPY`, or `COPY · N lines` when more than one line is +selected. The row under the box reads `v select · a block · y yank · esc`. + +## What copy mode copies, and what it refuses + +Freezing snapshots the rows both painted and plain. The conversation underneath keeps +streaming, and none of it moves the rows you are reading. Leaving rejoins the live +edge — the room's edge if a room was frozen. + +**What comes out** is the plain text with the left rail lifted — the stem under an +expanded tool call, the hairline beside a fenced block or a blockquote — along with +the indent in front of it and any trailing padding. The one-off marks `› ` and `· ` +are deliberately **kept**, because they say who was speaking. + +**How it reaches your clipboard:** OSC 52, written in band, so it works over ssh and +inside a container with no display. When `TERM` starts with `screen` or `tmux` it is +wrapped in tmux's DCS passthrough with every ESC doubled. It uses the clipboard +selection, not the primary one. + +**Refusals while copy mode is up:** -**There is no copy mode.** Until 2026-09-22 `ctrl+b` (and `/copy`) froze the conversation -and handed the keyboard to a reader — `v` marked, `a` took a block, `y` yanked, `esc` -left, and the status line read `COPY`. It was removed whole that day, on the judgement -that it was no use beside the mouse. `ctrl+b` is bound to **nothing** in a conversation -and in a task's room; on home it still moves the caret in the box one cell left, as -`←` does. `/copy` is not a command, and typing it answers -`there is no command called /copy · / lists them`. +- A click does nothing. +- A paste is declined, and your clipboard keeps the text. +- There is no hover. +- The selection highlight is a background, and a terminal below ANSI256 gets no + highlight at all. Read the span off the `COPY · N lines` count instead. It is the + strongest of the three backgrounds this screen draws — a shade above the one under + the pointer and the one under a chosen row — because a selection is held open and + runs across many lines at once, and you are looking for both of its ends. -**To copy text out of the conversation, use the mouse.** Drag across the rows and the -text is on your clipboard the moment you release — see *Selecting text with your mouse* -below. If you would rather your terminal did the selecting, `ctrl+s` hands it the pointer -until your next key. Both leave through the same in-band clipboard write, so they work -over ssh and inside tmux; a paste from either carries the words with the drawn rails -lifted, and a code block comes out as its source without the hairline or the `↳` wrap -mark. Inside a room the drag works the same way over the room's own rows. +**An image drawn in an expansion copies as what it is on screen** — rows of `▀`, with +the colour stripped, which is no use to anybody. Take the dim line under it instead: +it is the picture's whole absolute path. It is also a link you can click, the same as +every other real file path on this screen — see "click a file path to open it" on the +"what is on the screen" page. What copy mode gives you is the plain path, with the link +stripped off it. ## Chords that mean more than one thing @@ -2955,8 +3017,8 @@ sentence in the box and go on typing into the same words. Two more chords surprise people: -- **`ctrl+b` does nothing.** It was copy mode until 2026-09-22 and is bound to nothing - now; copying is the mouse's — drag, or `ctrl+s`. +- **`ctrl+b` is copy mode, not emacs "left".** The alternate screen took your + terminal's selection away, and copy mode is what buys it back. - **`ctrl+e` means two things** depending on whether the box is empty: end of line when there is text, open the most recent thinking block when there is not. - **`ctrl+w` closes a tab wherever you press it**: in a conversation the tab in front, @@ -3080,8 +3142,7 @@ been running longest**, which is the one you are waiting on. While a command can be kept, this meaning takes precedence over hiding or restoring the task column, so the column stays where it was. With no such command the key belongs to -the column as described above. `esc` interrupts and does not change; `ctrl+b`, which was -copy mode beside it, is bound to nothing at all now. +the column as described above. `ctrl+b` is copy mode and `esc` interrupts; neither changes. ## When a settings change lands diff --git a/internal/manual/chat/screen.md b/internal/manual/chat/screen.md index cd1426d930..cf3b2e77f4 100644 --- a/internal/manual/chat/screen.md +++ b/internal/manual/chat/screen.md @@ -560,7 +560,7 @@ background band. It was right-aligned until 2026-09-09, and out there beside the column it was the one thing on the frame nobody saw. It is not drawn at all when there is no gap row (a short window), when the label is -wider than the frame, while a room is open, or while the fullscreen +wider than the frame, in copy mode, while a room is open, or while the fullscreen roster is up. A room keeps its own edge: `ctrl+l` inside a room scrolls the room, not the conversation. @@ -675,8 +675,8 @@ answer to being scrolled away: it offers the way back rather than taking it. Pre `ctrl+l`, clicking the chip, or scrolling down to the bottom yourself re-arms following, and from then on new output keeps you at the edge again. -Two things do deliberately put you back at the bottom, because in each you asked for -it: sending a message, and queueing one with `ctrl+q`. +Three things do deliberately put you back at the bottom, because in each you asked for +it: sending a message, queueing one with `ctrl+q`, and leaving copy mode. ## The line above the message box (the legend) — the model, the machine in brackets after it, and why the conversation's name is not on it @@ -886,7 +886,7 @@ a mark means: at the start or a live `/standing`, `/orders`, or `/task` tag later in the draft. Help rows chip their leading command too. Nothing else borrows the mark, so it never highlights a slash word the send path will ignore. -- **A key chord is brighter ink and never a background.** `ctrl+s`, `esc`, `↑↓` step up a +- **A key chord is brighter ink and never a background.** `ctrl+b`, `esc`, `↑↓` step up a tier; they do not get a chip. - **Nothing here is ever drawn in the accent.** The accent marks the one live or chosen thing on a screen — your own `›`, the rail — and a line that appears and scrolls away is @@ -1151,7 +1151,7 @@ Four things about it: have been with no such rule. - **The row you are on is never faded**, wherever it has been scrolled to — including when it is the very last row before the fold. Neither is a row under the mouse. -- **Nothing you are reading fades.** The conversation is untouched: a +- **Nothing you are reading fades.** The conversation and copy mode are untouched: a transcript is read line by line and every line of it is the content, not context. - **Rows never alternate light and dark.** codeaf draws no striped lists anywhere. Rows are told apart by spacing, and groups inside a list by a blank line — never by a rule, @@ -1233,8 +1233,11 @@ words: | `waiting · your call` | an approval, standing or saved-program question requires an answer, or a task proposal has no countdown | the question hue, bold | | `stopping · detaching in 7s` | you pressed `esc` and the turn has not finished letting go yet; the count is what is left of the 10-second bound before codeaf detaches | dim | | `interrupted` | the last turn was stopped by hand and is over | the bad hue | +| `COPY` or `COPY · 12 lines` | copy mode | accent | `stopping` outranks `waiting · your call`, and `waiting · your call` outranks `working`. +Copy mode outranks everything, because it is the only state about the keyboard rather +than about the turn. **A door at rest whose work outlived its turn is not `idle`.** Handing a task out ends your turn, and the node it started works on for minutes with nothing happening in the @@ -1809,8 +1812,8 @@ a source line. The marker sits outside the code plane, so it can never be mistak something the code said. Breaks prefer a space in the back half of the row and go mid-token when there is none — a 40-cell URL in a 30-cell column has no break in it. -A drag across the block copies its source whole, wrapped rows included, and the paste -carries neither the hairline nor the `↳`. +Copying takes the block whole: `a` in copy mode selects the run of code rows around the +cursor, wrapped rows included, and the paste carries neither the hairline nor the `↳`. This used to be true only under 60 columns. Above it a long line was **cut** — with an ellipsis at some widths and with nothing at all at others — so the same answer was whole @@ -1970,7 +1973,7 @@ below is drawn as plain text on purpose: ## Copying a path, and why a reply cannot make its own link -**What you copy is the plain path.** A mouse drag and `/export` strip the +**What you copy is the plain path.** Copy mode (`ctrl+b`, or `/copy`) and `/export` strip the escape sequences, so a path leaves this conversation as the characters you can read, and an exported `.md` has no terminal machinery in it. Your terminal's own select-and-copy takes the visible characters too. @@ -2023,7 +2026,7 @@ When there is no offer: An **open** table keeps its foot at every width, because the foot is the only way back from a choice you made. -A drag copies the rendered rows, so opening a table is the only way to put its real +Copy mode yanks the rendered rows, so opening a table is the only way to put its real content on the clipboard. A closed one offers the ellipses you can already see. ## What opening a table actually does @@ -2850,8 +2853,8 @@ Four rungs, detected once from what your terminal says it can do: tint. - **NoColor** — no escape sequences at all, weight included. -Backgrounds — the hover band, the selection band, the stronger band under a drag -selection, and the chip behind a recognized slash command — are drawn only at +Backgrounds — the hover band, the selection band, the stronger band under a copy-mode +or drag selection, and the chip behind a recognized slash command — are drawn only at ANSI256 and above. There is no weight that means "this row", so a slash command falls back to bold and a hovered row to nothing. @@ -3291,7 +3294,7 @@ repaints in colours measured against your real background rather than an assumed Four things are re-aimed when it lands: - **The three background bands** — the row under the pointer, the chosen row, and a - drag selection — are built out of your own background colour, moved away from + copy-mode selection — are built out of your own background colour, moved away from itself by a fixed amount. They inherit your terminal's tint, and on a 256-colour terminal they still land on greys, never on a hue. - **The reading tiers** — ink, muted, dim — are checked against the real background and diff --git a/internal/manual/chat/sessions-and-rewind.md b/internal/manual/chat/sessions-and-rewind.md index 9ff9fa04ec..854f48b14a 100644 --- a/internal/manual/chat/sessions-and-rewind.md +++ b/internal/manual/chat/sessions-and-rewind.md @@ -226,14 +226,14 @@ Files, commands and git state are untouched by all of this. Only the conversatio when neither tier can open at all — the session has no rewind ability, or there is no legal place to cut. `/rewind` will not raise an empty timeline. -**`/rewind` opens nothing at all** in five states, silently: the timeline is already open, the -inline mode is already on, a task room is open, the settings panel is open, +**`/rewind` opens nothing at all** in six states, silently: the timeline is already open, the +inline mode is already on, copy mode is on, a task room is open, the settings panel is open, or the task rail is full. The commands page lists them. **`esc` `esc` does nothing.** `esc` will not arm rewind when something else on the surface holds the keyboard. That is: the settings sheet, the deck, the expand view, the model picker, the resume roster, the connections panel, the command menu, the completion list, the -welcome box, a recall walk, an open room, the rail hold, fullscreen rail, an +welcome box, copy mode, a recall walk, an open room, the rail hold, fullscreen rail, an approval question, an awaited task proposal, an active guard, any pending connect ask, and a pending harness offer. Close or answer that thing first. @@ -865,7 +865,7 @@ not several terminals. These come back with it: left. These are forgotten, and each is something you were in the *middle* of or a door onto -something the whole terminal shares: an inline rewind or an open rewind timeline, +something the whole terminal shares: copy mode, an inline rewind or an open rewind timeline, the expand sheet, the deliverables shelf, every picker and panel, the settings panel, the status deck, the task page, and the task column's focus. diff --git a/internal/manual/chat/starting-codeaf.md b/internal/manual/chat/starting-codeaf.md index cd00cbd66e..6df039bd43 100644 --- a/internal/manual/chat/starting-codeaf.md +++ b/internal/manual/chat/starting-codeaf.md @@ -771,7 +771,7 @@ This is not the record of what codeaf sent the model: that is a separate file, r codeaf ships with this manual compiled into it, and it reads it with a tool called `manual` rather than answering about itself from memory. So "what can you -do?", "what does ctrl+s do?", "can you read a PDF?" and "why did you just ask me +do?", "what does ctrl+b do?", "can you read a PDF?" and "why did you just ask me that?" are all fair questions to type straight into the conversation. If the manual has nothing on something, that usually means codeaf does not do it, and it will tell you so instead of inventing an answer. diff --git a/internal/manual/chat/tasks.md b/internal/manual/chat/tasks.md index c71cb0d1f0..db5ca07878 100644 --- a/internal/manual/chat/tasks.md +++ b/internal/manual/chat/tasks.md @@ -3328,6 +3328,7 @@ local conversation the same page tails that log live. | legend hint | `esc interrupt` while a turn runs | `x stop` while there is work to stop, `↑↓ history` mid-walk, nothing otherwise | | the model on the status row | the conversation's model | `task <the task's model>` | | clicking that model | opens the picker and switches the conversation | opens the picker and switches **that task**, from its next request — and does nothing at all once the task has landed | +| `ctrl+b` | freezes the transcript | freezes the room's own rows | | scroll position | the conversation's | the room's own, kept separately | | attachments | the tray sends pictures | a room's box sends words only | | proposals | drawn as cards | never — a task's own pieces start without asking you | @@ -3531,7 +3532,9 @@ see *A task's room after a restart*. `pgup`/`pgdown` scroll a page, the mouse wheel scrolls, and reaching the bottom re-sticks to the live edge. `↑`/`↓` walk your history first and only scroll a line when there is no -history to walk — see *Typing in a task's room*. +history to walk — see *Typing in a task's room*. `ctrl+b` freezes the room's rows for +copying — one known wrinkle: leaving copy mode rejoins the conversation's live edge, so +freezing a room while the conversation was scrolled up loses that scroll. The task's elapsed clock freezes while you stand in its room. That number exists to ask whether you should go and look; being there is the answer. Nothing is stopped, only @@ -4507,7 +4510,7 @@ What else you can do yourself, on a task that is running: | walk into it | click it, `enter` on it, or `→` over an empty box | | talk to it | `enter` on a sentence in its room | | read its whole transcript | its room | -| copy text out of it | drag across its rows with the mouse, in its room | +| copy text out of it | `ctrl+b` in its room | | refer to it in conversation | `@<slug>` | | leave it | `esc`, `←`, or `←←` — the work keeps running | | stop it | `x`, or `Stop` on its room's facts row — one confirmation card, always | diff --git a/internal/manual/chat/what-i-can-do.md b/internal/manual/chat/what-i-can-do.md index 94a9a6d860..d29226f73c 100644 --- a/internal/manual/chat/what-i-can-do.md +++ b/internal/manual/chat/what-i-can-do.md @@ -66,7 +66,7 @@ disk?" below for what comes back and what it costs. Over `--host`, `read`, `write`, `edit` and `ls` run on the other machine, inside the workspace shown for the session. A path in a task brief is read there too. A path the model names in its reply can be opened here: codeaf confirms it on the far disk and -fetches it through a short-lived local file door. `ctrl+s`, mouse drag-copy +fetches it through a short-lived local file door. Copy mode, `ctrl+s`, mouse drag-copy and `m puts it in your message` only copy or compose words on this screen, so they work the same way over a connection and do not move a file. diff --git a/internal/manual/chat/working-indicator.md b/internal/manual/chat/working-indicator.md index d5790ff3df..e5a6967531 100644 --- a/internal/manual/chat/working-indicator.md +++ b/internal/manual/chat/working-indicator.md @@ -18,14 +18,10 @@ cradle, ripple, accordion and infinity all mean the same thing: work is ongoing. The indicator disappears when the work finishes, is interrupted, or needs your answer. A task that is paused, finished, failed, or no longer being read through -a working connection does not animate. The logo draws under a question or steer -you sent in this turn; a turn that started on its own shows the usual waiting -text instead. - -It used to step aside in copy mode as well, so that a frozen page never held a -row that was about to move. There is no copy mode to step aside for since -2026-09-22 — `ctrl+b` and `/copy` are gone, and copying is the mouse (see the -*keys* page). +a working connection does not animate. It also steps aside in copy mode, so that a +frozen page never holds a row that was about to move. The logo draws under a +question or steer you sent in this turn; a turn that started on its own shows +the usual waiting text instead. Screen-reader mode, monochrome or ASCII terminals, and windows too small for the single-line mark keep the existing text and compact status indicators. The mark diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index 8150f0d7d9..73e9020abd 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -552,10 +552,13 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { // nothing, and by the one who wants a title off the list. {"ctrl+b on home does nothing", "keys"}, {"can I copy text off the home screen", "keys"}, - // Copy mode went on 2026-09-22; these are the person who remembers it. + // Copy mode, asked by somebody who wants to get words off the screen. + // It was taken out on 2026-09-22 and put back on 2026-09-23 — the owner + // wanted the feature kept and only its tip dropped — so these have to + // reach the pages that describe it working. {"how do I copy text out of the conversation", "keys"}, {"is there a copy mode", "keys"}, - {"what happened to /copy", "commands"}, + {"what does /copy do", "commands"}, // The cross on a tip row, asked by somebody who pressed it and watched // the row answer with a different sentence. {"what does the x on the hint row do", "hints-and-tips"}, diff --git a/internal/tui3/app.go b/internal/tui3/app.go index 8d6e60d525..ad01d4e064 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -2435,6 +2435,9 @@ type app struct { // all; closed, it costs the frame nothing. subPage subPage + // copy is the frozen viewport a person reads and yanks out of (copymode.go). + // Closed, it costs the frame nothing. + copy copyMode // rew is the rewind mode: the cut line through the transcript, the points it // can sit on, and the draft it is holding (rewind.go). Closed, it costs the // frame nothing. @@ -2841,6 +2844,7 @@ func newApp(ctx context.Context, opts Options) *app { // the memo for any of them (models.go's [app.learnModelLists]). a.learnModelLists() a.prepareModelServices() + a.copy.mark = -1 // AND THE REDUCER IS BUILT WITH WHAT THIS PAGE IS, which is the whole of the // difference between a chat's transcript and any other (feed.go states the // law the hooks exist to keep). It is built here and not in the literal above @@ -3638,6 +3642,17 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { } return a, nil } + // COPY MODE OWNS THE WHEEL while it is up, because the viewport it froze + // is the thing the wheel would otherwise move (copymode.go). + if a.copy.on { + switch msg.Mouse().Button { + case tea.MouseWheelUp: + a.copyScroll(-3) + case tea.MouseWheelDown: + a.copyScroll(3) + } + return a, nil + } if a.questionDialogWheel(msg) { return a, nil } @@ -3836,8 +3851,11 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { if a.pasteEdit.open { return a, nil } - if a.setup.open { - // A CLICK THROUGH THE SETUP SCREEN LANDS ON NOTHING: it is three + if a.copy.on || a.setup.open { + // A click in copy mode acts on nothing: the rows under the pointer are + // a FROZEN snapshot, and expanding a call in it would be expanding a + // row that is no longer where the conversation says it is. The setup + // screen is the same for the pointer's own reason: it is three // keystrokes, and a press through it would land on a frame that is // not being drawn (firstrun.go). return a, nil @@ -4210,9 +4228,10 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { // pointer crossing the window sends one per cell — so [app.setHover] // repaints only when the row under it actually changed (hover.go). // - // The linear tier has no hover at all — there is no pointer — and drops - // it here rather than paying for a hit-test per cell. - if a.linear { + // Two surfaces have no hover at all and drop it here rather than paying + // for a hit-test per cell: the frozen viewport (nothing under the pointer + // is actionable) and the linear tier (there is no pointer). + if a.copy.on || a.linear { return a, nil } // A MOVE WITH THE LEFT BUTTON DOWN IS THE SWEEP, read before every hover: @@ -6733,7 +6752,7 @@ func (a *app) linkHoverAt(x int, r row) int { // the mouse turned off (config's ui.mouse): /model with no argument opens the // same picker, and the help sheet says so. func (a *app) statusPress(x, y int) bool { - if a.at(pageSettings) || a.pick.open { + if a.copy.on || a.at(pageSettings) || a.pick.open { return false } // THE ROW IS RESOLVED BEFORE THE COLUMN, and that order is load-bearing: @@ -6890,6 +6909,10 @@ func (a *app) slash(line string) tea.Cmd { // (budget.go). return a.budget(rest) + case "copy": + a.enterCopy() + return nil + case "select": // It ANSWERS when there is nothing to hand over, because this one was // typed out on purpose: silence after a deliberate command reads as a @@ -8205,6 +8228,14 @@ func (a *app) paste(text string) tea.Cmd { if text == "" { return nil } + // COPY MODE IS A READER, and it is modal for the clipboard exactly as it is + // for the keyboard (copymode.go): the box a paste would land in is off + // screen behind a frozen viewport, so the text would go somewhere nobody can + // see it. The clipboard still holds it, which is the difference between + // declining a paste and losing one. + if a.copy.on { + return nil + } // A PASTE IS SOMEBODY STARTING WORK, so it dismisses the welcome box on the // same terms every other input does (welcome.go): everything puts the box // away except the two keys that walk its list, and a paste is not one of diff --git a/internal/tui3/background.go b/internal/tui3/background.go index ed8a237519..d8b162180e 100644 --- a/internal/tui3/background.go +++ b/internal/tui3/background.go @@ -17,9 +17,7 @@ package tui3 // // ── THE KEY, AND WHY THIS ONE ── // -// esc is the interrupt and is not for sale — ctrl+b, which used to be copy -// mode and was named here beside it, is bound to nothing now (clipboard.go). -// ctrl+g +// ctrl+b is copy mode and esc is the interrupt, and neither is for sale. ctrl+g // already closes and restores the task column; while a foreground command can // be kept, this reading wins, and with none the column keeps the key. It is // plain BEL so every terminal on every platform delivers it, and it needs no diff --git a/internal/tui3/bargein.go b/internal/tui3/bargein.go index bd79d937e5..2abea40fe0 100644 --- a/internal/tui3/bargein.go +++ b/internal/tui3/bargein.go @@ -120,7 +120,7 @@ func (a *app) bargeOffered() bool { // outright, and the rail holds it while the roster is up. Every one of these // is read above the plain switch in [app.key], so the guard is here for the // HINT's sake as much as the key's. - return !a.roomOpen() && !a.rew.on && !a.railHold + return !a.roomOpen() && !a.copy.on && !a.rew.on && !a.railHold } // bargeIn is the chord: the draft goes, and the turn stops. diff --git a/internal/tui3/bottomchrome_test.go b/internal/tui3/bottomchrome_test.go index 4d340ff42d..e8d4ca02c3 100644 --- a/internal/tui3/bottomchrome_test.go +++ b/internal/tui3/bottomchrome_test.go @@ -242,6 +242,28 @@ func TestTheJumpKeyReturnsToTheLiveEdgeWithADraftInTheBox(t *testing.T) { } } +// A FROZEN VIEWPORT MANAGES ITS OWN EDGE (copymode.go), so the chip stays off +// while it is up rather than offering to scroll a snapshot. +func TestTheJumpChipStaysOffInCopyMode(t *testing.T) { + a := scrolledApp(t, 24) + a.scroll(-6) + if !a.jumpShowing() { + t.Fatal("the chip was never up to begin with") + } + a.enterCopy() + if a.jumpShowing() { + t.Fatal("the chip is up over a frozen viewport") + } + if got := chipAtRow(strings.Split(frame(a), "\n")); got >= 0 { + t.Fatalf("copy mode drew the chip on row %d", got) + } + // Leaving copy mode rejoins the edge on its own, so the chip stays off. + a.exitCopy() + if a.jumpShowing() { + t.Fatal("thawing left the reader off the live edge") + } +} + // THE POINTER HAS TO BE ON THE CHIP, not merely on its row: it is the one target // on this surface narrower than the line it is drawn on. func TestTheJumpChipBrightensUnderThePointerAndNowhereElse(t *testing.T) { diff --git a/internal/tui3/bundle_test.go b/internal/tui3/bundle_test.go index 88b0e5ec4c..1af070759a 100644 --- a/internal/tui3/bundle_test.go +++ b/internal/tui3/bundle_test.go @@ -395,19 +395,128 @@ func TestTheNotificationSanitizesItsFields(t *testing.T) { } } -// ── 6. THE CLIPBOARD WIRE ────────────────────────────────────────────────── +// ── 6. COPY MODE ──────────────────────────────────────────────────────────── -// A copy leaves in band, and inside tmux it goes wrapped in the passthrough -// with every ESC doubled — the bare form is silently eaten there. -func TestACopyTakesTheTmuxPassthroughInsideTmux(t *testing.T) { +// copyApp is a surface with a known transcript, in copy mode. +func copyApp(t *testing.T) *app { + t.Helper() + a := newTestApp(&fakeAgent{model: "m"}) + for _, line := range []string{"alpha", "bravo", "charlie", "delta", "echo"} { + a.entries = append(a.entries, entry{kind: entryNote, text: line}) + } + a.touch() + drive(t, a, ctrlKey('b')) + if !a.copy.on { + t.Fatal("ctrl+b did not enter copy mode") + } + return a +} + +// ctrl+b freezes, ↑ moves, esc leaves — and the status line says which of those +// is happening. +func TestCopyModeFreezesScrollsAndExits(t *testing.T) { + a := copyApp(t) + frozen := append([]string(nil), a.copy.rows...) + + word, _ := a.stateWord() + if word != "COPY" { + t.Fatalf("the status line says %q", word) + } + // The whole frame draws, and it says so where a person is already looking. + screen := plain(frame(a)) + if !strings.Contains(screen, "COPY") { + t.Fatalf("the frame does not say COPY:\n%s", screen) + } + if !strings.Contains(screen, "alpha") { + t.Fatalf("the frozen conversation is not on screen:\n%s", screen) + } + + // The conversation keeps going underneath and the frozen rows do not move. + a.note("this arrived after the freeze") + if len(a.copy.rows) != len(frozen) { + t.Fatalf("the snapshot grew from %d to %d rows", len(frozen), len(a.copy.rows)) + } + body, _ := a.bodyRows(a.width, a.viewHeight()) + for _, r := range body { + if strings.Contains(plain(r.text), "after the freeze") { + t.Fatal("the frozen viewport drew a row that arrived after it froze") + } + } + + at := a.copy.at + drive(t, a, key("up")) + if a.copy.at != at-1 { + t.Fatalf("↑ moved the cursor from %d to %d", at, a.copy.at) + } + drive(t, a, key("down")) + if a.copy.at != at { + t.Fatalf("↓ did not come back: %d", a.copy.at) + } + // The cursor cannot walk off either end. + for i := 0; i < len(a.copy.rows)+5; i++ { + a.copyScroll(-1) + } + if a.copy.at != 0 { + t.Fatalf("the cursor walked past the top: %d", a.copy.at) + } + + drive(t, a, key("esc")) + if a.copy.on { + t.Fatal("esc did not leave copy mode") + } + if !a.stick { + t.Fatal("leaving copy mode did not rejoin the live edge") + } + if word, _ := a.stateWord(); word == "COPY" { + t.Fatal("the status line still says COPY") + } +} + +// v marks, y yanks the span, and what reaches the terminal is an OSC 52 write +// carrying the plain text of the marked rows. +func TestCopyModeYanksTheMarkedSpan(t *testing.T) { + a := copyApp(t) + // Park on a row whose text is known, then mark two rows. + a.copy.at = rowWith(t, a, "charlie") + drive(t, a, key("v")) + if a.copy.mark < 0 { + t.Fatal("v did not drop a mark") + } + drive(t, a, key("up")) + from, to := a.copySpan() + if to-from != 1 { + t.Fatalf("the span is %d rows", to-from+1) + } + if word, _ := a.stateWord(); word != "COPY · 2 lines" { + t.Fatalf("the status line does not count the span: %q", word) + } + + payload := yank(t, a) + if payload != " · bravo\n · charlie" { + t.Fatalf("the yank carried %q", payload) + } + if a.copy.mark >= 0 { + t.Fatal("the mark survived the yank") + } + // v again with no mark set copies the cursor's line alone. + a.copy.at = rowWith(t, a, "delta") + if got := yank(t, a); got != " · delta" { + t.Fatalf("an unmarked yank carried %q", got) + } +} + +// Inside tmux the same write goes out wrapped in the passthrough, with every +// ESC doubled — the bare form is silently eaten there. +func TestTheYankTakesTheTmuxPassthroughInsideTmux(t *testing.T) { bare := osc52("hi", false) if want := "\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte("hi")) + "\a"; bare != want { - t.Fatalf("the bare write is %q", bare) + t.Fatalf("the bare form is %q", bare) } wrapped := osc52("hi", true) - if !strings.HasPrefix(wrapped, "\x1bPtmux;\x1b\x1b]52;c;") || !strings.HasSuffix(wrapped, "\a\x1b\\") { - t.Fatalf("the tmux write is not a passthrough with its ESCs doubled: %q", wrapped) + if !strings.HasPrefix(wrapped, "\x1bPtmux;\x1b\x1b]52;c;") || !strings.HasSuffix(wrapped, "\x1b\\") { + t.Fatalf("the tmux form is %q", wrapped) } + for term, want := range map[string]bool{ "tmux-256color": true, "screen-256color": true, "screen": true, "xterm-256color": false, "": false, "alacritty": false, @@ -418,6 +527,100 @@ func TestACopyTakesTheTmuxPassthroughInsideTmux(t *testing.T) { } } +// yank presses y and returns the text the clipboard write carries. +func yank(t *testing.T, a *app) string { + t.Helper() + cmd, taken := a.copyKey(key("y")) + if !taken || cmd == nil { + t.Fatal("y did not yank") + } + raw, ok := cmd().(tea.RawMsg) + if !ok { + t.Fatalf("the yank is a %T, not a raw write", cmd()) + } + seq, _ := raw.Msg.(string) + body := strings.TrimSuffix(strings.TrimPrefix(seq, "\x1b]52;c;"), "\a") + decoded, err := base64.StdEncoding.DecodeString(body) + if err != nil { + t.Fatalf("the payload is not base64: %q", seq) + } + return string(decoded) +} + +// "a" takes the whole thing under the cursor rather than a range of lines a +// person had to count out, and what comes back is pasteable: the column the +// frame draws down the left of a block is the frame speaking, not the text. +func TestCopyModeTakesTheBlockUnderTheCursorAndYanksItClean(t *testing.T) { + a := newTestApp(&fakeAgent{model: "m"}) + a.pal = newPalette(tokens.TrueColor, false) + // THE CALL COMES BEFORE THE ANSWER, which is the order a turn actually runs + // in and the order THE ANSWER HIERARCHY reads (hierarchy.go): prose with more + // work under it in the same turn is narration and is drawn at the working + // tier, so an answer written above its own tool call would be demoted here — + // and this test is about copying the ANSWER's fence. + a.entries = append(a.entries, + entry{kind: entryUser, text: "how do I print?"}, + entry{kind: entryTool, tool: "read", text: "main.go", status: toolOK, open: true, + detail: toolDetail{Output: "line one\nline two"}}, + entry{kind: entryAssistant, settled: true, text: "Use fmt:\n\n```go\nfmt.Println(\"hi\")\nif ok {\n\tprintln(1)\n}\n```\n\nThat is all."}, + ) + // Copying a result starts with that result on screen, so open both the + // completed turn and its caption before freezing the copy view. + a.openWorkfold(0) + a.setCapOpen(a.conversation(), 1, true) + a.touch() + drive(t, a, ctrlKey('b')) + + // On a code row, "a" takes the fence — and only the fence, without the + // hairline the renderer draws beside it. + a.copy.at = rowWith(t, a, "println(1)") + drive(t, a, key("a")) + if got := yank(t, a); got != "fmt.Println(\"hi\")\nif ok {\n println(1)\n}" { + t.Fatalf("the code block came out as %q", got) + } + + // Pressing it again on the same row widens to the answer the fence lives in, + // which is the block the code row also belongs to. + a.copy.at = rowWith(t, a, "println(1)") + drive(t, a, key("a")) + drive(t, a, key("a")) + got := yank(t, a) + // Flush at both ends: this is the turn's ANSWER, so it carries no work + // gutter for the yank to have to strip (hierarchy.go). + if !strings.HasPrefix(strings.TrimLeft(got, " "), "Use fmt:") || !strings.HasSuffix(got, "That is all.") { + t.Fatalf("the second press did not widen to the answer: %q", got) + } + if strings.Contains(got, tokens.GlyphCodeGutter) { + t.Fatalf("the answer carried the code hairline: %q", got) + } + + // A tool's output is a block too, and its stem is chrome the same way. + a.copy.at = rowWith(t, a, "line two") + drive(t, a, key("a")) + if got := yank(t, a); !strings.Contains(got, "line one\nline two") { + t.Fatalf("the tool result came out as %q", got) + } else if strings.Contains(got, "│") { + t.Fatalf("the tool result carried its stem: %q", got) + } + + // A blank belongs to nothing, so "a" there guesses at nothing. + blank := -1 + for i, line := range a.copy.text { + if strings.TrimSpace(line) == "" && a.copy.owner[i] < 0 { + blank = i + break + } + } + if blank < 0 { + t.Fatal("the layout emitted no blank between the blocks") + } + a.copy.at, a.copy.mark = blank, -1 + drive(t, a, key("a")) + if a.copy.mark >= 0 { + t.Fatal("a blank row was taken as a block") + } +} + // A waiting sign-in is the one thing here a person needs somewhere else, so a // press on the card copies the link whole — and the card says it did. func TestPressingAWaitingSignInCopiesItsLink(t *testing.T) { @@ -532,6 +735,18 @@ func TestTheSelectCommandAnswersWhenThereIsNothingToHandOver(t *testing.T) { } } +// rowWith is the frozen row holding a word. +func rowWith(t *testing.T, a *app, word string) int { + t.Helper() + for i, line := range a.copy.text { + if strings.Contains(line, word) { + return i + } + } + t.Fatalf("no frozen row holds %q:\n%s", word, strings.Join(a.copy.text, "\n")) + return -1 +} + // ── 7. THE LIGHT LADDER ───────────────────────────────────────────────────── // The authored values, and the one law that has to hold on the rung where hues diff --git a/internal/tui3/clipboard.go b/internal/tui3/clipboard.go deleted file mode 100644 index e97e55fd27..0000000000 --- a/internal/tui3/clipboard.go +++ /dev/null @@ -1,195 +0,0 @@ -package tui3 - -import ( - "encoding/base64" - "strings" - - "github.com/Agent-Field/codeaf/internal/tui2/tokens" -) - -// GETTING TEXT OUT: the mouse, and the wire a copy goes down. -// -// This surface runs in the alt screen (view.go), which is what lets the -// conversation scroll under its own anchor — and which takes the terminal's -// own scrollback and selection away in the same breath. Two doors give a -// person their text back, and both are the mouse's: a drag across the rows -// copies what it covers the moment the button is released (dragselect.go), and -// ctrl+s hands the pointer to the terminal so its own drag works -// ([app.releaseMouse]). Every copy this surface makes — the sweep, a path -// under a row, a sign-in link, a job's log path — leaves through [osc52]. -// -// THERE WAS A KEYBOARD DOOR, AND IT IS GONE. Copy mode — ctrl+b and /copy, a -// frozen viewport read with ↑↓, marked with v, taken by block with a and -// yanked with y — stood here from the alt screen's first day until 2026-09-22, -// when the owner judged it no use beside the drag and had it removed whole: -// the mode, the command, the status word, the keys row, the tip that taught -// it, and every gate the rest of the surface kept on it. ctrl+b is bound to -// nothing in a conversation now, and home's box keeps it as the caret's left. -// -// ── ctrl+s ────────────────────────────────────────────────────────────────── -// -// The surface takes the pointer by default (view.go): while it holds it, -// dragging across an answer scrolls or hovers, and the drag every person alive -// already knows selects nothing. dragselect.go answers that with a sweep of -// its own; this is the other answer, for a person who wants THEIR terminal's -// selection — its word-doubling, its rectangle, its paste buffer. -// -// ctrl+s gives the pointer to the terminal. Drag, copy the way that terminal -// copies, and the next key pressed here takes it back — there is no mode to -// leave and nothing to remember, because the gesture that ends it is the -// gesture that follows it anyway. While it is out, one dim line says so. -// -// It is deliberately NOT the ui.mouse setting under another name. The setting -// is a standing decision about how this surface behaves; this is a person -// reaching for one paragraph, which is a thing they do between two keystrokes -// and should not have to open a panel for. -// -// ── WHY OSC 52 AND NOT A CLIPBOARD LIBRARY ────────────────────────────────── -// -// Because the terminal may not be on this machine. OSC 52 is a clipboard write -// carried in-band, over the same pipe the drawing goes down, so it works -// through ssh and through a container without a display, and it is the only -// mechanism that does. Inside tmux it needs the passthrough wrapper — tmux -// eats sequences it does not recognize unless they are addressed to it — -// hence [tmuxTerm] and the doubled ESC below. -// -// Bubble Tea has [tea.SetClipboard], which sends the bare form. This file -// builds its own because the bare form is the one that silently does nothing -// inside a multiplexer, which is where a lot of these sessions live. - -// selectKey hands the pointer over. ctrl+s survives the trip: the terminal is -// in raw mode while this surface is up, and raw mode is exactly what turns off -// the flow control that would otherwise have eaten it. -const selectKey = "ctrl+s" - -// releaseMouse toggles the handover, and reports whether the surface had a -// pointer to hand over at all. With ui.mouse off the terminal already has it, -// so there is nothing to do and nothing to say — the drag being asked for -// works already. -func (a *app) releaseMouse() bool { - if !a.mouse { - return false - } - a.released = !a.released - // THE HOVER GOES WITH IT. Nothing reports where the pointer is any more, so - // whatever row was lit stays lit — a band under a pointer that has since - // moved somewhere else entirely, sitting on the screen for the whole of the - // drag somebody is trying to make (hover.go). - if a.released { - a.dropHover() - } - a.touch() - return true -} - -// takeMouseBack ends the handover on the person's next keystroke. It reports -// whether it did anything so the caller can stay quiet when it did not. -func (a *app) takeMouseBack() bool { - if !a.released { - return false - } - a.released = false - a.touch() - return true -} - -// ── WHAT A COPY CARRIES ───────────────────────────────────────────────────── -// -// What a person copies must be what a person could PASTE. The sweep keeps the -// rows plain as well as painted, and it lifts the column the renderer draws -// down the left of a block — the stem under an expanded tool call, the -// hairline beside a fence. Those cells are the frame saying "these rows are -// one thing"; in a paste buffer they are a box-drawing character welded to the -// front of every line of somebody's stack trace. - -// copyRails are the columns this surface draws down the LEFT of a block and -// repeats on every one of its rows: the stem an expanded tool's output hangs -// from (styles.go), under both its glyph sets, and the hairline beside a fenced -// code block or a blockquote (markdown.go). -// -// The one-off marks are NOT here and must not be. "› " on a message and "· " on -// a note sit on the first row of a block and say who is speaking, which is a -// fact somebody quoting a conversation usually wants kept. A rail says nothing -// except "these rows are one thing", which the paste already shows. -// The wrapped-code row's lead is here for [copyCodeRow]'s reason: a line the -// renderer split is still one line of source, and a paste that carried `↳ ` into -// the middle of it would be a paste that does not compile. -var copyRails = []string{railCont, railContASCII, - tokens.GlyphCodeGutter + " ", mdContMark + tokens.GlyphCodeGutter + " "} - -// copyClean is one drawn row as it should reach a clipboard: the drawn left -// rail lifted, and the trailing cells — hover padding, row padding — with it. -func copyClean(line string, gut int) string { - // THE READING GUTTER IS FRAME FURNITURE AND NEVER TEXT (gutter.go), so it - // comes off before anything else is decided. It is dropped by width rather - // than by trimming, because what is left of the indent below IS text about - // the block — a tool's output sits two columns in, and a copy that lost that - // would paste a diff with its hierarchy flattened. - line = strings.TrimPrefix(line, strings.Repeat(" ", gut)) - trimmed := strings.TrimLeft(line, " ") - indent := line[:len(line)-len(trimmed)] - for _, rail := range copyRails { - if rest, ok := strings.CutPrefix(trimmed, rail); ok { - // The indent BEFORE the rail goes too. It is the block's own inset on - // the frame, not anything the text said about itself, and code inside a - // fence keeps its own indentation because that sits after the rail. - return strings.TrimRight(rest, " ") - } - } - return strings.TrimRight(indent+trimmed, " ") -} - -// copyCodeRow reports whether a drawn row belongs to a fenced block: it sits -// behind the hairline markdown puts down the left of one. -// -// IT ALSO KNOWS THE CONTINUATION MARKER, and it has to. A code line too long -// for the frame is wrapped rather than cut (markdown.go's [segmentedMarkdown]), -// and the row carrying the rest of it opens on [mdContMark] where its -// neighbours open on spaces — so a run of code rows read by the gutter alone -// ENDED at the first wrapped line (markdownwrap_test.go holds the case). -func copyCodeRow(line string) bool { - trimmed := strings.TrimLeft(line, " ") - trimmed = strings.TrimPrefix(trimmed, mdContMark) - return strings.HasPrefix(trimmed, tokens.GlyphCodeGutter) -} - -// ── OSC 52 ────────────────────────────────────────────────────────────────── - -// osc52 is a clipboard write, in the form the terminal in front of us speaks. -// -// ESC ] 52 ; c ; <base64> BEL the sequence itself -// ESC P tmux ; <the sequence, ESC doubled> ESC \ the same, addressed to tmux -// -// The "c" is the CLIPBOARD selection rather than "p" (primary): a copy made on -// purpose, and primary is what a terminal's own drag fills. -func osc52(payload string, tmux bool) string { - seq := "\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte(payload)) + "\a" - if !tmux { - return seq - } - // tmux forwards a DCS passthrough to the terminal underneath it verbatim, - // with one rule: every ESC inside must be doubled, or tmux reads the first - // one as the end of the passthrough. - return "\x1bPtmux;" + strings.ReplaceAll(seq, "\x1b", "\x1b\x1b") + "\x1b\\" -} - -// tmuxTerm reports whether this surface is inside a multiplexer, from TERM -// alone. TERM is what tmux and screen both set for the session they host -// ("screen-256color", "tmux-256color"), and it is the one answer that is true -// whether the multiplexer was started before this process or around it — -// $TMUX, the other candidate, is unset in a pane that inherited its environment -// from somewhere else. -func tmuxTerm(env func(string) string) bool { - if env == nil { - return false - } - term := strings.ToLower(strings.TrimSpace(env("TERM"))) - return strings.HasPrefix(term, "screen") || strings.HasPrefix(term, "tmux") -} - -func clampInt(v, low, high int) int { - if high < low { - return low - } - return min(max(v, low), high) -} diff --git a/internal/tui3/commands.go b/internal/tui3/commands.go index 9a418e55dc..cf38cec0e8 100644 --- a/internal/tui3/commands.go +++ b/internal/tui3/commands.go @@ -353,17 +353,18 @@ var commands = []command{ // be the most expensive pun on the surface. {name: "cache", desc: "the shared build cache — how big, and where"}, {name: "cache", args: "clean", desc: "…delete it to free disk · asks before anything is removed"}, - // THE TWO DOORS ONTO GETTING TEXT OUT, and they sit beside /help because - // that is where a person goes with the question they answer. The key behind - // the first is the least discoverable on the surface — nothing on the screen - // says it exists — and "why can I not copy this" is the first question this - // surface gets asked. /select is the mouse's way (clipboard.go); /copy, the - // keyboard's, was copy mode and went with it on 2026-09-22. + // THE THREE DOORS ONTO GETTING TEXT OUT, and they sit beside /help because + // that is where a person goes with the question they answer. The keys behind + // the first two are the least discoverable on the surface — nothing on the + // screen says either exists — and "why can I not copy this" is the first + // question this surface gets asked. /copy is the keyboard's way, /select the + // mouse's (copymode.go). // - // /export is the second and it is a different KIND of answer: that one hands + // /export is the third and it is a different KIND of answer: those two hand // over what is on the screen, and this one writes the whole conversation to a - // file somebody can send (export.go). It is last of the two because it is + // file somebody can send (export.go). It is last of the three because it is // the one a person reaches for once, at the end. + {name: "copy", desc: "read the conversation back and copy from it · ctrl+b"}, {name: "select", desc: "drag to select with your mouse · ctrl+s"}, // TWO ROWS FOR ONE COMMAND, the way /model has two. A single row carrying // <path> would make the bare form — which is the one nearly everybody wants — @@ -1034,6 +1035,7 @@ func helpText(file string, chords chordSpelling) string { // rows at the foot of this list already use. helpKeyRow(spellOutKey, "over a draft: spell it out · what it means · enter adds it to yours"), "ctrl+o expand this turn's tool calls · click one to open it · in a task, scroll up does too", + "ctrl+b copy mode · ↑↓ move · v marks · a takes the block · y yanks", "ctrl+s drag to select with your mouse · any key ends it", "enter mid-answer: stops the current reply and steers these words in", // AND THE THIRD THING TO DO WITH A SENTENCE TYPED OVER A RUNNING ANSWER diff --git a/internal/tui3/copymode.go b/internal/tui3/copymode.go new file mode 100644 index 0000000000..41bb80d7ef --- /dev/null +++ b/internal/tui3/copymode.go @@ -0,0 +1,512 @@ +package tui3 + +import ( + "encoding/base64" + "strings" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/Agent-Field/codeaf/internal/tui2/tokens" +) + +// COPY MODE: ctrl+b, and the reason it exists is the alt screen. +// +// This surface runs in the alt screen (view.go), which is what lets the +// conversation scroll under its own anchor — and which takes the terminal's own +// scrollback and selection away in the same breath. A person who wants the +// stack trace that just went past has, without this, exactly two options: drag +// the mouse across it while the surface is also tracking the mouse, or scroll +// up and read it out loud to themselves. +// +// So: ctrl+b freezes the viewport and hands the keyboard to a reader. +// +// ↑ ↓ pgup pgdn move the cursor through the frozen rows +// v drop a mark, or lift it +// a take the whole block under the cursor +// y yank — the cursor's line, or the marked span +// esc leave, and rejoin the live edge +// COPY in the status line, for as long as it is up +// +// ── WHY "a" ── +// +// Because the thing a person wants is almost never a range of lines: it is an +// answer, a tool's output, a fenced block of code. Building that out of v and +// nine presses of ↓ is the reader doing arithmetic to say something it already +// knows — every row of the snapshot remembers which block it came from, and a +// fence announces itself by the hairline down its left. So "a" asks for the +// block and the cursor stays where it was, which means a on a code row inside +// an answer takes the code, and a again takes the answer around it. +// +// ── WHAT COMES OUT ── +// +// What a person copies must be what a person could PASTE. That is why the +// snapshot is kept plain as well as painted, and it is why the yank also lifts +// the column the renderer draws down the left of a block — the stem under an +// expanded tool call, the hairline beside a fence. Those cells are the frame +// saying "these rows are one thing"; in a paste buffer they are a box-drawing +// character welded to the front of every line of somebody's stack trace. +// +// ── WHAT "FREEZES" MEANS ── +// +// The rows are SNAPSHOTTED on entry, painted and plain, and the frozen list is +// what the frame draws until esc. The conversation underneath keeps going — a +// turn that was running keeps streaming, tool rows keep landing, the follow-up +// queue keeps draining — and none of it moves the rows being read. That is the +// whole point: a viewport that reflowed under somebody trying to copy line 14 +// would hand them line 19. +// +// The snapshot is also why a click does nothing while it is up (app.go): row 14 +// of a frozen list is not row 14 of the conversation, and a click that expanded +// "whatever is there now" would open a call the person cannot see. +// +// ── WHY OSC 52 AND NOT A CLIPBOARD LIBRARY ── +// +// Because the terminal may not be on this machine. OSC 52 is a clipboard write +// carried in-band, over the same pipe the drawing goes down, so it works +// through ssh and through a container without a display, and it is the only +// mechanism that does. Inside tmux it needs the passthrough wrapper — tmux +// eats sequences it does not recognize unless they are addressed to it — hence +// [tmuxTerm] and the doubled ESC below. +// +// Bubble Tea has [tea.SetClipboard], which sends the bare form. This file +// builds its own because the bare form is the one that silently does nothing +// inside a multiplexer, which is where a lot of these sessions live. + +// ── AND THE OTHER DOOR: ctrl+s ────────────────────────────────────────────── +// +// Copy mode is the keyboard's answer. This is the mouse's, and it exists +// because the surface takes the pointer by default (view.go): while it holds +// it, dragging across an answer scrolls or hovers, and the drag every person +// alive already knows selects nothing. +// +// ctrl+s gives the pointer to the terminal. Drag, copy the way that terminal +// copies, and the next key pressed here takes it back — there is no mode to +// leave and nothing to remember, because the gesture that ends it is the +// gesture that follows it anyway. While it is out, one dim line says so. +// +// It is deliberately NOT the ui.mouse setting under another name. The setting +// is a standing decision about how this surface behaves; this is a person +// reaching for one paragraph, which is a thing they do between two keystrokes +// and should not have to open a panel for. + +// selectKey hands the pointer over. ctrl+s survives the trip: the terminal is +// in raw mode while this surface is up, and raw mode is exactly what turns off +// the flow control that would otherwise have eaten it. +const selectKey = "ctrl+s" + +// releaseMouse toggles the handover, and reports whether the surface had a +// pointer to hand over at all. With ui.mouse off the terminal already has it, +// so there is nothing to do and nothing to say — the drag being asked for +// works already. +func (a *app) releaseMouse() bool { + if !a.mouse { + return false + } + a.released = !a.released + // THE HOVER GOES WITH IT. Nothing reports where the pointer is any more, so + // whatever row was lit stays lit — a band under a pointer that has since + // moved somewhere else entirely, sitting on the screen for the whole of the + // drag somebody is trying to make (hover.go). + if a.released { + a.dropHover() + } + a.touch() + return true +} + +// takeMouseBack ends the handover on the person's next keystroke. It reports +// whether it did anything so the caller can stay quiet when it did not. +func (a *app) takeMouseBack() bool { + if !a.released { + return false + } + a.released = false + a.touch() + return true +} + +// copyKeysWord is the keys row while the viewport is frozen, under either box: +// the reader's keys are the only keys that work, so they are the only keys the +// row may name. +const copyKeysWord = "v select · a block · y yank · esc" + +// copyMode is the frozen viewport's whole state. The zero value is off, except +// for mark, which [newApp] sets to -1 — nothing is marked. +type copyMode struct { + on bool + // The reading gutter belongs to the snapshot, even after a resize. + gutter int + // rows is the snapshot as it is drawn, and text the same rows stripped of + // every escape sequence. Two slices rather than one strip-per-yank because + // what a person copies must be what a person could paste: SGR in a paste + // buffer is line noise in whatever they paste it into. + rows []string + text []string + // owner is which block each row came from — the index into the list that was + // frozen, or -1 for a blank the spacing law emitted between two of them + // (render.go's [app.layout]). It is recorded at the freeze rather than + // recomputed, for the reason the rows themselves are: the list underneath + // keeps moving, and a block resolved afterwards would be a different block. + owner []int + // at is the cursor's row, top the first row on screen, and mark the other + // end of the selection or -1. + at, top, mark int +} + +// enterCopy freezes the viewport. It lays the page out after copy mode owns it, +// so a transient sign of life and the blank that belongs to it cannot become +// transcript, then parks the cursor on the last row a person can see — the live +// edge is what they were watching when they reached for the key. +func (a *app) enterCopy() { + if a.copy.on { + return + } + width := a.bodyWidth() + height := a.viewHeight() + // COPY OWNS THE PAGE BEFORE IT IS LAID OUT, so a transient sign of life and + // the blank that belongs to it cannot become transcript (worklogo.go, + // #1384). + a.copy.on = true + rows := a.layout(width) + if len(rows) == 0 { + a.copy.on = false + return + } + snapshot := make([]string, 0, len(rows)) + plain := make([]string, 0, len(rows)) + owner := make([]int, 0, len(rows)) + for _, r := range rows { + snapshot = append(snapshot, r.text) + plain = append(plain, ansi.Strip(r.text)) + owner = append(owner, r.entry) + } + top := a.offsetFor(len(rows), height) + at := min(top+height-1, len(rows)-1) + a.copy = copyMode{on: true, gutter: textGutterCols(width), rows: snapshot, text: plain, owner: owner, at: at, top: top, mark: -1} + a.noticeEvent(eventCopyEntered) + a.touch() +} + +// exitCopy thaws it and rejoins the live edge, because a reader who has +// finished reading wants the conversation back. +// +// WHICHEVER EDGE WAS FROZEN. A room's rows are what [app.freezeRoom] snapshots, +// so thawing back onto the transcript's edge would drop the reader out of the +// page they were reading and lose the conversation's scroll on the way (room.go +// carried this as a known seam; the room's own stick is what closes it). +func (a *app) exitCopy() { + a.copy = copyMode{mark: -1} + if a.room != nil { + a.room.stick = true + a.roomTouched() + return + } + a.stick = true + a.follow() + a.touch() +} + +// copyKey routes the frozen viewport's keys and says whether it took one. +// +// It takes EVERYTHING except the keys read above it (ctrl+c is the door and is +// never modal), because copy mode is a reading mode: a keystroke that fell +// through to the draft would type into a box the person cannot see the effect +// of. +func (a *app) copyKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { + if !a.copy.on { + return nil, false + } + switch msg.String() { + case "esc", "ctrl+b", "q": + a.exitCopy() + case "up", "k": + a.copyScroll(-1) + case "down", "j": + a.copyScroll(1) + case "pgup": + a.copyScroll(-a.scrollPage()) + case "pgdown": + a.copyScroll(a.scrollPage()) + case "home": + a.copyScroll(-len(a.copy.rows)) + case "end": + a.copyScroll(len(a.copy.rows)) + case "v": + a.copyMark() + case "a": + a.copyBlock() + case "y": + return a.copyYank(), true + } + return nil, true +} + +// copyScroll moves the cursor and keeps it on screen. The window follows the +// CURSOR rather than the other way round: there is no second position to keep +// in step, so there is nothing for the two to disagree about. +func (a *app) copyScroll(delta int) { + c := &a.copy + c.at = clampInt(c.at+delta, 0, len(c.rows)-1) + height := a.viewHeight() + if height < 1 { + height = 1 + } + switch { + case c.at < c.top: + c.top = c.at + case c.at >= c.top+height: + c.top = c.at - height + 1 + } + c.top = clampInt(c.top, 0, max(len(c.rows)-height, 0)) + a.touch() +} + +// copyMark drops the far end of a selection, or lifts it. The cursor is always +// the NEAR end: v then ↓↓↓ grows the span downward, exactly as it does in every +// other reader that has this key. +func (a *app) copyMark() { + if a.copy.mark >= 0 { + a.copy.mark = -1 + } else { + a.copy.mark = a.copy.at + } + a.touch() +} + +// copyBlock selects the whole thing the cursor is standing in, and leaves the +// cursor where it was so the next press can widen from the same spot. +// +// It asks the narrower question first. A fenced code block is a run of rows +// carrying the code hairline, and inside an answer it is almost always what +// somebody reached for — so a on a code row takes the code, and a again, now +// that the run is already selected, takes the answer it lives in. Anywhere +// else there is only the block, and one press has it. +// +// A blank row belongs to nothing (the spacing law emits it between two things, +// render.go's [app.layout]), so a there does nothing rather than guessing at +// which neighbour was meant. +func (a *app) copyBlock() { + c := &a.copy + if c.at < 0 || c.at >= len(c.text) { + return + } + from, to, ok := c.fenceAt(c.at) + if !ok || (c.mark == from && c.at == to) || (c.mark == to && c.at == from) { + from, to, ok = c.entryAt(c.at) + } + if !ok { + return + } + // The mark is the FAR end and the cursor the near one, which is the rule the + // whole mode runs on ([app.copyMark]): dropping them the other way round + // would make the next ↓ shrink a selection the person just widened. + if c.at <= from { + c.at, c.mark = from, to + } else { + c.at, c.mark = to, from + } + a.touch() +} + +// fenceAt is the run of code rows around one row: rows drawn behind the +// hairline markdown puts down the left of a fenced block (markdown.go). +func (c *copyMode) fenceAt(at int) (int, int, bool) { + if !copyCodeRow(c.text[at]) { + return 0, 0, false + } + from, to := at, at + for from > 0 && copyCodeRow(c.text[from-1]) { + from-- + } + for to < len(c.text)-1 && copyCodeRow(c.text[to+1]) { + to++ + } + return from, to, true +} + +// copyCodeRow reports whether a drawn row belongs to a fenced block: it sits +// behind the hairline markdown puts down the left of one. +// +// IT ALSO KNOWS THE CONTINUATION MARKER, and it has to. A code line too long +// for the frame is wrapped rather than cut (markdown.go's [segmentedMarkdown]), +// and the row carrying the rest of it opens on [mdContMark] where its +// neighbours open on spaces — so a run of code rows read by the gutter alone +// ENDED at the first wrapped line, and `a` selected the top half of a block. +func copyCodeRow(line string) bool { + trimmed := strings.TrimLeft(line, " ") + trimmed = strings.TrimPrefix(trimmed, mdContMark) + return strings.HasPrefix(trimmed, tokens.GlyphCodeGutter) +} + +// entryAt is the run of rows one block of the frozen list occupies. +func (c *copyMode) entryAt(at int) (int, int, bool) { + if at >= len(c.owner) || c.owner[at] < 0 { + return 0, 0, false + } + block := c.owner[at] + from, to := at, at + for from > 0 && c.owner[from-1] == block { + from-- + } + for to < len(c.owner)-1 && c.owner[to+1] == block { + to++ + } + return from, to, true +} + +// copySpan is the selected range, inclusive, low first. +func (a *app) copySpan() (int, int) { + if a.copy.mark < 0 { + return a.copy.at, a.copy.at + } + if a.copy.mark <= a.copy.at { + return a.copy.mark, a.copy.at + } + return a.copy.at, a.copy.mark +} + +// copyYank writes the selection to the system clipboard and lifts the mark. +// +// It stays IN copy mode: a person copying a stack trace out of a log usually +// wants the next thing under it too, and esc is right there. The mark is lifted +// because leaving it would make the next y copy the same span again by +// accident. +func (a *app) copyYank() tea.Cmd { + from, to := a.copySpan() + if from < 0 || to >= len(a.copy.text) { + return nil + } + lines := make([]string, 0, to-from+1) + for _, line := range a.copy.text[from : to+1] { + lines = append(lines, copyClean(line, a.copy.gutter)) + } + a.copy.mark = -1 + a.touch() + return tea.Raw(osc52(strings.Join(lines, "\n"), a.tmux)) +} + +// copyRails are the columns this surface draws down the LEFT of a block and +// repeats on every one of its rows: the stem an expanded tool's output hangs +// from (styles.go), under both its glyph sets, and the hairline beside a fenced +// code block or a blockquote (markdown.go). +// +// The one-off marks are NOT here and must not be. "› " on a message and "· " on +// a note sit on the first row of a block and say who is speaking, which is a +// fact somebody quoting a conversation usually wants kept. A rail says nothing +// except "these rows are one thing", which the paste already shows. +// The wrapped-code row's lead is here for [copyCodeRow]'s reason: a line the +// renderer split is still one line of source, and a paste that carried `↳ ` into +// the middle of it would be a paste that does not compile. +var copyRails = []string{railCont, railContASCII, + tokens.GlyphCodeGutter + " ", mdContMark + tokens.GlyphCodeGutter + " "} + +// copyClean is one frozen row as it should reach a clipboard: the drawn left +// rail lifted, and the trailing cells — hover padding, row padding — with it. +func copyClean(line string, gut int) string { + // THE READING GUTTER IS FRAME FURNITURE AND NEVER TEXT (gutter.go), so it + // comes off before anything else is decided. It is dropped by width rather + // than by trimming, because what is left of the indent below IS text about + // the block — a tool's output sits two columns in, and a yank that lost that + // would paste a diff with its hierarchy flattened. + line = strings.TrimPrefix(line, strings.Repeat(" ", gut)) + trimmed := strings.TrimLeft(line, " ") + indent := line[:len(line)-len(trimmed)] + for _, rail := range copyRails { + if rest, ok := strings.CutPrefix(trimmed, rail); ok { + // The indent BEFORE the rail goes too. It is the block's own inset on + // the frame, not anything the text said about itself, and code inside a + // fence keeps its own indentation because that sits after the rail. + return strings.TrimRight(rest, " ") + } + } + return strings.TrimRight(indent+trimmed, " ") +} + +// copyRows is what the frame draws while the viewport is frozen: the visible +// slice of the snapshot, with the selection highlighted. +// +// The selection wears THE GROUND LADDER's MARK step (styles.go), which is the +// loudest of the three and exists for exactly this: a span, held open, running +// across many rows at once. It used to wear the pointer's own step, and that +// was one statement doing two jobs — "the pointer is here" and "these forty +// rows are what a yank would take" are not the same claim and may not be the +// same tint. The cursor is the moving end of the span, which is visible in the +// moving, and a terminal below ANSI256 gets no highlight at all and reads the +// span off the status line's count instead. +func (a *app) copyRows(width, height int) ([]row, int) { + if height <= 0 || len(a.copy.rows) == 0 { + return nil, 0 + } + from, to := a.copySpan() + // The window is clamped HERE as well as in [app.copyScroll], because the + // frame can shrink between the two: a resize while the viewport is frozen + // leaves a top that was legal for the old height, and a slice taken from it + // would draw an empty screen rather than the rows somebody is reading. + top := clampInt(a.copy.top, 0, max(len(a.copy.rows)-height, 0)) + end := min(top+height, len(a.copy.rows)) + out := make([]row, 0, end-top) + for i := top; i < end; i++ { + text := a.copy.rows[i] + if i >= from && i <= to { + text = a.pal.mark(text, width) + } + out = append(out, row{text: text, entry: -1}) + } + if pad := height - len(out); pad > 0 { + return out, pad + } + return out, 0 +} + +// copyWord is what the status line says while this is up. It carries the count +// as well as the mode, because a marked span longer than the screen is a span a +// person cannot otherwise measure. +func (a *app) copyWord() string { + from, to := a.copySpan() + if n := to - from + 1; n > 1 { + return "COPY · " + itoa(n) + " lines" + } + return "COPY" +} + +// ── OSC 52 ────────────────────────────────────────────────────────────────── + +// osc52 is a clipboard write, in the form the terminal in front of us speaks. +// +// ESC ] 52 ; c ; <base64> BEL the sequence itself +// ESC P tmux ; <the sequence, ESC doubled> ESC \ the same, addressed to tmux +// +// The "c" is the CLIPBOARD selection rather than "p" (primary): a yank is a +// deliberate copy, and primary is what a mouse drag fills. +func osc52(payload string, tmux bool) string { + seq := "\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte(payload)) + "\a" + if !tmux { + return seq + } + // tmux forwards a DCS passthrough to the terminal underneath it verbatim, + // with one rule: every ESC inside must be doubled, or tmux reads the first + // one as the end of the passthrough. + return "\x1bPtmux;" + strings.ReplaceAll(seq, "\x1b", "\x1b\x1b") + "\x1b\\" +} + +// tmuxTerm reports whether this surface is inside a multiplexer, from TERM +// alone. TERM is what tmux and screen both set for the session they host +// ("screen-256color", "tmux-256color"), and it is the one answer that is true +// whether the multiplexer was started before this process or around it — +// $TMUX, the other candidate, is unset in a pane that inherited its environment +// from somewhere else. +func tmuxTerm(env func(string) string) bool { + if env == nil { + return false + } + term := strings.ToLower(strings.TrimSpace(env("TERM"))) + return strings.HasPrefix(term, "screen") || strings.HasPrefix(term, "tmux") +} + +func clampInt(v, low, high int) int { + if high < low { + return low + } + return min(max(v, low), high) +} diff --git a/internal/tui3/detach.go b/internal/tui3/detach.go index 90a2b6b3e5..cb0882e8a6 100644 --- a/internal/tui3/detach.go +++ b/internal/tui3/detach.go @@ -399,9 +399,10 @@ func (a *app) clearConversation() { // else. a.workOpen = map[int]bool{} a.dropHover() - // A cut line is a mode a person is in the middle of, and there is no honest - // way to be in the middle of one in a conversation nobody is looking at - // (rewind.go). + // A frozen viewport and a cut line are modes a person is in the middle of, + // and there is no honest way to be in the middle of one in a conversation + // nobody is looking at (copymode.go, rewind.go). + a.copy = copyMode{mark: -1} a.rew = rewindMode{} a.rewSay, a.rewSayAt = "", time.Time{} // The rail goes with its nodes, its rooms and its pilots (task.go). diff --git a/internal/tui3/dragspan_test.go b/internal/tui3/dragspan_test.go index 6250377ad5..0cc95b38cc 100644 --- a/internal/tui3/dragspan_test.go +++ b/internal/tui3/dragspan_test.go @@ -63,7 +63,7 @@ func TestASweepAcrossRowsTakesTailWholeAndHead(t *testing.T) { t.Fatalf("the ends are wrong:\n%q", got) } // The middle row keeps the indent it is drawn with, as copy mode keeps it: - // an inset is the block's own and pastes as such (clipboard.go's copyClean). + // an inset is the block's own and pastes as such (copymode.go's copyClean). if !strings.Contains(got, "the person wants fmt\n") { t.Fatalf("the middle row is not taken whole:\n%q", got) } diff --git a/internal/tui3/foot.go b/internal/tui3/foot.go index cbccf83953..0a8eb44b18 100644 --- a/internal/tui3/foot.go +++ b/internal/tui3/foot.go @@ -400,7 +400,7 @@ func (a *app) doorPress(door statusDoor) (tea.Cmd, bool) { // answer, and laying it out is what writes the doors — read the other way // round, this would be testing a column from the frame before this one. func (a *app) statusDoorPress(x, y int) (tea.Cmd, bool) { - if a.at(pageSettings) || a.pick.open { + if a.copy.on || a.at(pageSettings) || a.pick.open { return nil, false } mark, ok := a.chromeAt(y) @@ -697,7 +697,7 @@ func seamSpans(head, model, rider, rung, gate string) (hudSpan, hudSpan, hudSpan // conversation's model; a room's own door is on its status row // ([app.statusPress]). func (a *app) legendModelPress(x, y int) bool { - if a.at(pageSettings) || a.pick.open { + if a.copy.on || a.at(pageSettings) || a.pick.open { return false } mark, ok := a.chromeAt(y) @@ -727,7 +727,7 @@ func (a *app) legendModelPress(x, y int) bool { // resolved word read back, so the work goes to the loop rather than being run // under the pointer. func (a *app) legendEffortPress(x, y int) (tea.Cmd, bool) { - if a.at(pageSettings) || a.pick.open { + if a.copy.on || a.at(pageSettings) || a.pick.open { return nil, false } mark, ok := a.chromeAt(y) @@ -747,7 +747,7 @@ func (a *app) legendEffortPress(x, y int) (tea.Cmd, bool) { // WALKS THE GATE'S WHEEL ONE STOP on the rung's own terms (approvalchip.go): // one press, one step, with a note describing the resulting posture. func (a *app) legendApprovalPress(x, y int) (tea.Cmd, bool) { - if a.at(pageSettings) || a.pick.open || a.roomOpen() { + if a.copy.on || a.at(pageSettings) || a.pick.open || a.roomOpen() { return nil, false } mark, ok := a.chromeAt(y) diff --git a/internal/tui3/gutter_test.go b/internal/tui3/gutter_test.go index b050c9f290..b55c18fe33 100644 --- a/internal/tui3/gutter_test.go +++ b/internal/tui3/gutter_test.go @@ -193,7 +193,7 @@ func TestATaskPageIsReadTwoColumnsInToo(t *testing.T) { // A YANK PASTES WHAT WAS SAID AND NOT THE FRAME IT WAS SAID IN. The gutter is // furniture; the indent under it is the block's own hierarchy and stays -// (clipboard.go's [copyClean]). +// (copymode.go's [copyClean]). func TestAYankLiftsTheGutterAndKeepsTheIndent(t *testing.T) { if got := copyClean(" the parser guard is back", spacingConversationLead); got != "the parser guard is back" { t.Fatalf("a yank of a guttered row pasted %q", got) diff --git a/internal/tui3/home.go b/internal/tui3/home.go index 5cc48dddf6..5ab48b5b93 100644 --- a/internal/tui3/home.go +++ b/internal/tui3/home.go @@ -4039,7 +4039,7 @@ func (a *app) homeDoorOpen() bool { // character typed, because it is a door and not chrome — the space it takes is // the keys row's, which the frame already has (render.go's [app.footHint]). func (a *app) homeDoorShowing() bool { - return a.homeDoorOpen() && a.input.empty() && !a.rew.on + return a.homeDoorOpen() && a.input.empty() && !a.copy.on && !a.rew.on } // homeDoorPress is a click on that advertisement. diff --git a/internal/tui3/homeslash.go b/internal/tui3/homeslash.go index c24a7853dc..0f3ae82d50 100644 --- a/internal/tui3/homeslash.go +++ b/internal/tui3/homeslash.go @@ -201,7 +201,7 @@ func homeFate(word, rest string) string { case "land", "workspace": return fateBehind case "files", "permissions", "connect", "harness", "subharness", "autonomy", - "select", "rewind", "compact", "export", "drafts", "manual", "folder": + "copy", "select", "rewind", "compact", "export", "drafts", "manual", "folder": // /manual IS HERE SINCE 2026-09-22 and not among the answers: it is a // turn of a conversation now (manualcmd.go), and a turn needs one. As // an answer it printed the pages into the conversation BEHIND home, diff --git a/internal/tui3/hop.go b/internal/tui3/hop.go index 5cca02ec2e..3caf404684 100644 --- a/internal/tui3/hop.go +++ b/internal/tui3/hop.go @@ -341,7 +341,7 @@ func (a *app) hopAvailable() bool { // that opened over either would be drawn over a gesture somebody is in the // middle of. They are asked HERE because this claim is read above the place // router and so does not pass through either of their own arbitration. -func (a *app) hopMayOpen() bool { return !a.composer.open } +func (a *app) hopMayOpen() bool { return !a.composer.open && !a.copy.on } // THERE USED TO BE A SECOND DOOR HERE, `hopOpenAll`: the card raised with its // fold already open, which is what the `Chats ▾` control at the right end of the diff --git a/internal/tui3/hover.go b/internal/tui3/hover.go index 24957ebe46..9ffb5a2236 100644 --- a/internal/tui3/hover.go +++ b/internal/tui3/hover.go @@ -628,7 +628,7 @@ func (a *app) hoverTarget(x, y int) hoverAt { // inside a room (roomseam.go). The home door at the other end of the // same line lights through its own reading (home.go's // [app.hoverHomeDoor]). - if a.pick.open { + if a.copy.on || a.pick.open { return hoverAt{} } // THE PROJECT IS ON THIS ROW ONLY AT THE PHONE TIER; everywhere else @@ -662,7 +662,7 @@ func (a *app) hoverTarget(x, y int) hoverAt { // identity's own row, then the columns the render recorded for the model. // Any of them answering differently here would be a name that brightens // and then does nothing. - if a.at(pageSettings) || a.pick.open { + if a.copy.on || a.at(pageSettings) || a.pick.open { return hoverAt{} } // AT PHONE WIDTH THE ROW IS A DECK AND THE DECK ANSWERS FOR BOTH OF ITS diff --git a/internal/tui3/input.go b/internal/tui3/input.go index 918215ae61..ce7d70a19e 100644 --- a/internal/tui3/input.go +++ b/internal/tui3/input.go @@ -637,6 +637,14 @@ func (a *app) key(msg tea.KeyPressMsg) tea.Cmd { return cmd } + // COPY MODE is modal, and it is modal one rung below ctrl+c for the same + // reason everything else here is: leaving is never modal. While it is up the + // surface is a reader, and a key that fell through to the draft would type + // into a box whose effect is off screen (copymode.go). + if cmd, taken := a.copyKey(msg); taken { + return cmd + } + // REWIND MODE IS MODAL AT THE SAME RUNG AND FOR THE SAME REASON (rewind.go): // while it is up the draft box is not on the frame at all — a mode bar stands // in its position — so a key that fell through to the editor would type into a @@ -936,11 +944,20 @@ func (a *app) key(msg tea.KeyPressMsg) tea.Cmd { return nil } + case "ctrl+b": + // FREEZE AND READ (copymode.go). ctrl+b used to be the emacs `left` here, + // alongside the arrow key that everybody actually presses, and it is spent + // on this instead: the alt screen took the terminal's own selection away, + // and getting text out of the conversation is a thing this surface could + // not do at all. ← is untouched. + a.enterCopy() + return nil + case selectKey: - // DRAG THE WAY YOU DRAG EVERYWHERE ELSE (clipboard.go). With the pointer + // DRAG THE WAY YOU DRAG EVERYWHERE ELSE (copymode.go). It is the mouse's + // half of ctrl+b and it sits beside it for that reason. With the pointer // already the terminal's it does nothing, because the drag it offers is - // one the person can already make. ctrl+b, which was copy mode's key - // beside this one until 2026-09-22, is bound to nothing now. + // one the person can already make. a.releaseMouse() return nil diff --git a/internal/tui3/jobpage.go b/internal/tui3/jobpage.go index 325315e45e..02413002da 100644 --- a/internal/tui3/jobpage.go +++ b/internal/tui3/jobpage.go @@ -169,7 +169,7 @@ func (a *app) jobPageKeyPress(msg tea.KeyPressMsg) (tea.Cmd, bool) { switch key := msg.String(); { case key == "ctrl+c", a.asking(), a.awaitingTask(), a.at(pageSettings), a.at(pageTasks), a.at(pageHome), a.deckShowing(), a.pick.open, - a.roster.open, a.welcome.open, a.menu.open, a.comp.open: + a.roster.open, a.copy.on, a.welcome.open, a.menu.open, a.comp.open: return nil, false } return a.jobPageKey(msg.String()), true diff --git a/internal/tui3/jumpchip.go b/internal/tui3/jumpchip.go index 5c55398a9c..3f215453ec 100644 --- a/internal/tui3/jumpchip.go +++ b/internal/tui3/jumpchip.go @@ -67,11 +67,12 @@ func jumpLabel(pal palette) string { // conversation shorter than the window has nothing below it, and a chip offering // to jump to a row that is already on screen is a chip that does nothing. func (a *app) jumpShowing() bool { - // THE BODY REGION HAS TO BE THE CONVERSATION. A room or the fullscreen - // roster has put the transcript off the frame entirely — a chip that - // offered to scroll a list nobody can see is the same mistake [app.rowAt] - // refuses to make. - if a.roomOpen() || a.railFull() { + // THE BODY REGION HAS TO BE THE CONVERSATION. A frozen viewport manages its + // own edge and rejoins it on the way out (copymode.go's [app.exitCopy]), and + // a room or the fullscreen roster has put the transcript off the frame + // entirely — a chip that offered to scroll a list nobody can see is the same + // mistake [app.rowAt] refuses to make. + if a.copy.on || a.roomOpen() || a.railFull() { return false } height := a.viewHeight() @@ -143,8 +144,8 @@ func (a *app) jumpPress(x, y int) bool { // never mean slightly different things. // // The edge is rejoined by ARMING THE STICK rather than by computing a bottom -// offset, which is how every other path back does it (welcome.go, app.go's -// /new): the offset is resolved from the row count at +// offset, which is how every other path back does it (welcome.go, app.go's /new, +// copymode.go's [app.exitCopy]): the offset is resolved from the row count at // draw time, so a stick armed here survives the four rows the turn streams // between this keystroke and the next frame. func (a *app) toLatest() { diff --git a/internal/tui3/markdownwrap_test.go b/internal/tui3/markdownwrap_test.go index 5e1b60eaeb..e686143efc 100644 --- a/internal/tui3/markdownwrap_test.go +++ b/internal/tui3/markdownwrap_test.go @@ -385,3 +385,45 @@ func TestAWrappedCodeRowIsMarkedAndAnUnwrappedOneIsNot(t *testing.T) { strings.Join(renderMarkdown("```go\n"+long+"\n```", 120), "\n")) } } + +// AND COPY MODE STILL SEES ONE BLOCK. `a` selects the run of code rows around +// the cursor, and it read that run off the gutter alone — so a wrapped line +// ENDED the run and the yank took the top half of the block. The paste carries +// the source and neither the hairline nor the marker. +func TestCopyModeTakesAWrappedFenceWholeAndPastesNoMarkers(t *testing.T) { + long := "x := " + strings.Repeat("aVeryLongIdentifier + ", 12) + "1" + rows := renderMarkdown("```go\n"+long+"\ny := 2\n```", 120) + text := make([]string, len(rows)) + for i, row := range rows { + text[i] = plain(row) + } + c := ©Mode{text: text} + + at := -1 + for i, row := range text { + if strings.Contains(row, mdContMark) { + at = i + break + } + } + if at < 0 { + t.Fatalf("nothing wrapped, so this case tests nothing:\n%s", strings.Join(text, "\n")) + } + from, to, ok := c.fenceAt(at) + if !ok { + t.Fatalf("a wrapped code row is not read as part of a fence: %q", text[at]) + } + if !strings.Contains(text[to], "y := 2") { + t.Fatalf("the block was cut at the wrapped row: rows %d-%d end on %q", from, to, text[to]) + } + var pasted []string + for _, row := range text[from : to+1] { + // These rows came straight from [renderMarkdown] and never went through + // the transcript's pass, so there is no reading gutter on them to lift. + pasted = append(pasted, copyClean(row, 0)) + } + joined := strings.Join(pasted, "") + if strings.Contains(joined, mdContMark) || strings.Contains(joined, tokens.GlyphCodeGutter) { + t.Fatalf("the paste carries the frame's own marks:\n%q", joined) + } +} diff --git a/internal/tui3/moneydoor.go b/internal/tui3/moneydoor.go index 2877b400d0..ef6d075861 100644 --- a/internal/tui3/moneydoor.go +++ b/internal/tui3/moneydoor.go @@ -66,7 +66,7 @@ func (a *app) moneyPress(x, y int) tea.Cmd { // moneyDoorAt is that question on its own, because the pointer asks it too: the // set that LIGHTS has to be the set the press acts on (hover.go's own law). func (a *app) moneyDoorAt(x, y int) bool { - if a.at(pageSettings) || a.pick.open { + if a.copy.on || a.at(pageSettings) || a.pick.open { return false } mark, ok := a.chromeAt(y) diff --git a/internal/tui3/notice.go b/internal/tui3/notice.go index 0b619a9e8b..17a06d3b7a 100644 --- a/internal/tui3/notice.go +++ b/internal/tui3/notice.go @@ -113,6 +113,8 @@ const ( // eventRewound is a rewind that landed, from either surface (rewind.go's // [app.rewindLand]). eventRewound = "rewound" + // eventCopyEntered is copy mode freezing the viewport (copymode.go). + eventCopyEntered = "copy-entered" // eventModelSwitched is the conversation's model changing by any door // (palette.go's [app.switchModel]). eventModelSwitched = "model-switched" @@ -192,7 +194,7 @@ const ( // refuse a retire rule that names a word nobody fires. var noticeEvents = []string{ eventBoot, eventTurnEnded, eventTaskStarted, eventTaskPageOpened, - eventMenuOpened, eventRewound, eventModelSwitched, + eventMenuOpened, eventRewound, eventCopyEntered, eventModelSwitched, eventCompacted, eventFilesOpened, eventResumeOpened, eventCostShown, eventStandingOpened, eventDeliverableMade, eventAsked, eventTaskTyped, eventManualAsked, eventTabReopened, eventAtOpened, eventAttached, @@ -500,10 +502,15 @@ var notices = []notice{ // The seat `ask for a picture, a voiceover, music or a video` held // until 2026-09-22, and `ctrl+b freezes the screen so you can read // and copy from it` for one build the same day, both the owner's - // call: copy mode was judged no use, and the rule for what happens - // to a question while nobody is at the keyboard is the one setting a - // person cannot guess exists until it has already decided something - // for them (autonomysheet.go). + // call: the rule for what happens to a question while nobody is at + // the keyboard is the one setting a person cannot guess exists until + // it has already decided something for them (autonomysheet.go). + // + // COPY MODE ITSELF IS NOT GONE, and this row is the only reason to + // think it might be. It was taken out on 2026-09-22 and put back on + // 2026-09-23 at the owner's word — "we don't need a hint, but don't + // remove the feature" — so `ctrl+b`, `/copy` and the frozen viewport + // all work and simply have no row here. id: "autonomy-rule", slot: slotHint, armed: spoken, text: "/autonomy sets how questions are handled while you are away", @@ -1177,7 +1184,7 @@ func (a *app) noticeHint() string { // noticeQuiet is whether nothing on the frame outranks a tip. func (a *app) noticeQuiet() bool { return a.input.empty() && a.state != stateWorking && a.showing() == nil && - !a.rew.on && !a.rewSheet.open && !a.menu.open && !a.comp.open && + !a.rew.on && !a.rewSheet.open && !a.copy.on && !a.menu.open && !a.comp.open && !a.pick.open && !a.roster.open && !a.asking() && !a.roomOpen() } diff --git a/internal/tui3/notice_test.go b/internal/tui3/notice_test.go index abe7000543..619c20f1bf 100644 --- a/internal/tui3/notice_test.go +++ b/internal/tui3/notice_test.go @@ -634,6 +634,13 @@ func TestEveryRetireEventIsProvedByItsGesture(t *testing.T) { t.Fatal(err) } }, + eventCopyEntered: func(t *testing.T, a *app) { + a.note("something to copy") + a.enterCopy() + if !a.copy.on { + t.Fatal("copy mode did not open") + } + }, eventModelSwitched: func(t *testing.T, a *app) { a.switchModel("openai/gpt-4.1", 1_000_000) }, eventCompacted: func(t *testing.T, a *app) { drive(t, a, compactedMsg{}) }, eventFilesOpened: func(t *testing.T, a *app) { a.slash("/files") }, diff --git a/internal/tui3/payload.go b/internal/tui3/payload.go index e8d3d05f03..1e09ddb958 100644 --- a/internal/tui3/payload.go +++ b/internal/tui3/payload.go @@ -44,7 +44,7 @@ import ( // thing wearing it is a mark that has to be read twice to learn which one it // is. A chord steps to the data hue instead, which is the same one-step move // every other datum makes and costs the budget nothing. So `/help` is chipped -// wherever it is written, `ctrl+s` wears the data hue wherever it is written, +// wherever it is written, `ctrl+b` wears the data hue wherever it is written, // and neither rule has an exception. // // ── STRATEGY OVER DECORATION ──────────────────────────────────────────────── diff --git a/internal/tui3/payload_test.go b/internal/tui3/payload_test.go index 88f00fea78..a586e8df75 100644 --- a/internal/tui3/payload_test.go +++ b/internal/tui3/payload_test.go @@ -121,7 +121,7 @@ func TestTheKeySheetLiftsItsChordsAndChipsItsCommands(t *testing.T) { rows := noteRows(a, help, columnFacts(help, true)...) body := strings.Join(rows, "\n") - for _, chord := range []string{"ctrl+s", "ctrl+o", "alt+enter", "@path"} { + for _, chord := range []string{"ctrl+b", "ctrl+o", "alt+enter", "@path"} { if !lifted(a.pal, body, chord) { t.Fatalf("the key sheet draws %q at the weight of the sentence beside it", chord) } @@ -158,8 +158,8 @@ func TestAStatusFigureReadsAboveItsLabel(t *testing.T) { // THE COLUMN IS READ BACK OFF THE TEXT, in both directions, so the fact list and // the note cannot be two spellings of one thing that drift apart. func TestColumnFactsReadEitherHalfOfATwoColumnNote(t *testing.T) { - text := "codeaf\n\n/help what you can type\nctrl+s drag to select\nsession · x.json" - if got := columnFacts(text, true); len(got) != 2 || got[0] != "/help" || got[1] != "ctrl+s" { + text := "codeaf\n\n/help what you can type\nctrl+b copy mode\nsession · x.json" + if got := columnFacts(text, true); len(got) != 2 || got[0] != "/help" || got[1] != "ctrl+b" { t.Fatalf("the leading column is not the two keys: %q", got) } if got := columnFacts(text, false); len(got) != 2 || got[0] != "what you can type" { diff --git a/internal/tui3/place_sessions.go b/internal/tui3/place_sessions.go index d137ae5b3f..c2338c2da1 100644 --- a/internal/tui3/place_sessions.go +++ b/internal/tui3/place_sessions.go @@ -891,7 +891,7 @@ func (a *app) taskSheetKeyPress(msg tea.KeyPressMsg) (tea.Cmd, bool) { return nil, false } switch { - case a.asking(), a.awaitingTask(), a.rew.on, a.rewSheet.open, a.welcome.open, + case a.asking(), a.awaitingTask(), a.copy.on, a.rew.on, a.rewSheet.open, a.welcome.open, a.menu.open, a.comp.open, a.guarding(), a.stopping(): return nil, false } diff --git a/internal/tui3/projectseam.go b/internal/tui3/projectseam.go index 4db45ecf46..e0a55d7d10 100644 --- a/internal/tui3/projectseam.go +++ b/internal/tui3/projectseam.go @@ -50,7 +50,7 @@ func (a *app) paintSeamProject(text string, span hudSpan, hovered bool) string { // ([app.hintRowKind]) — and it opens the folder chooser, which is what the // word is a door onto: the same sheet `/folder` opens. func (a *app) seamProjectPress(x, y int) (tea.Cmd, bool) { - if a.pick.open || a.roomOpen() || !a.seamProjectSpan.holds(x) { + if a.copy.on || a.pick.open || a.roomOpen() || !a.seamProjectSpan.holds(x) { return nil, false } mark, ok := a.chromeAt(y) diff --git a/internal/tui3/question.go b/internal/tui3/question.go index f20f0d8d02..87c14088ab 100644 --- a/internal/tui3/question.go +++ b/internal/tui3/question.go @@ -3959,7 +3959,7 @@ func (a *app) openQuestionRoom(head questionShown) tea.Cmd { // through to whatever is under it, exactly as a key does. func (a *app) questionPress(x, y int) (tea.Cmd, bool) { head, ok := a.questionHead() - if !ok { + if !ok || a.copy.on { return nil, false } set := a.questionSet() diff --git a/internal/tui3/render.go b/internal/tui3/render.go index 7f046cfa21..234c5bfb6d 100644 --- a/internal/tui3/render.go +++ b/internal/tui3/render.go @@ -2961,7 +2961,7 @@ func (a *app) stateSegment() (string, string) { if a.room != nil && !a.orchOpen() { return word, painted } - if a.state != stateWorking || a.asking() { + if a.state != stateWorking || a.asking() || a.copy.on { return word, painted } mark := tokens.Spinner(a.paints / spinnerStep) @@ -3094,6 +3094,16 @@ func (a *app) warmSegmentShort() string { // it is waiting for them. "your call" rather than "your answer" because it is // shorter and because it is what it is. func (a *app) stateWord() (string, string) { + // COPY OUTRANKS EVERYTHING, because it is the only state on this line that is + // about the KEYBOARD rather than about the turn. While the viewport is frozen + // the keys do something else entirely (copymode.go), and a status line that + // said "idle" would be describing the session correctly and the screen + // wrongly. The turn underneath keeps running; the row it would have claimed + // is back the moment esc is pressed. + if a.copy.on { + word := a.copyWord() + return word, a.pal.accent(word) + } // A sweep's receipt outranks the run state for the seconds it stands: the // person's eye is on the status line asking exactly one question — did the // copy land — and the turn's own word is back the moment it expires @@ -3846,6 +3856,8 @@ func (a *app) hintWord() string { // one line saying "enter" for both would be teaching nobody // (subharness.go). return a.subVerbs() + case a.copy.on: + return copyKeysWord case a.rew.on: // The rewind mode prints its own keys in the bar that replaced the draft // box (rewind.go), and a slot repeating them would be the surface saying diff --git a/internal/tui3/rewind.go b/internal/tui3/rewind.go index 437a011c89..892752c007 100644 --- a/internal/tui3/rewind.go +++ b/internal/tui3/rewind.go @@ -163,7 +163,7 @@ func (a *app) enterRewind() tea.Cmd { // The frame stack decides where this can be opened from: a room, a frozen // viewport and the fullscreen panels all draw over the place the mode bar // stands in, and a bar nobody can see is a mode nobody can leave (view.go). - if a.rew.on || a.rewSheet.open || a.roomOpen() || a.at(pageSettings) || a.railFull() { + if a.rew.on || a.rewSheet.open || a.copy.on || a.roomOpen() || a.at(pageSettings) || a.railFull() { return nil } agent, ok := a.rewinder() @@ -259,7 +259,7 @@ func (a *app) rewindReady() bool { case a.rewSheet.open, a.at(pageSettings), a.at(pageTasks), a.deck.open, a.expand.open, a.pick.open, a.roster.open, a.connPanel.open, a.menu.open, a.comp.open, a.welcome.open, - a.recalling(), a.roomOpen(), a.railHold, a.railFull(), + a.copy.on, a.recalling(), a.roomOpen(), a.railHold, a.railFull(), a.asking(), a.awaitingTask(), a.guard != nil, a.asksConnect(), a.asksHarness(): return false diff --git a/internal/tui3/rewindsheet.go b/internal/tui3/rewindsheet.go index 3ce1fa917a..b419ac0fb8 100644 --- a/internal/tui3/rewindsheet.go +++ b/internal/tui3/rewindsheet.go @@ -196,7 +196,7 @@ type rewindSheet struct { // reached from inside the inline mode, which is why that state is handled by the // lift below instead of being refused here. func (a *app) openRewindSheet() tea.Cmd { - if a.rewSheet.open || a.rew.on || a.roomOpen() || a.at(pageSettings) || a.railFull() { + if a.rewSheet.open || a.rew.on || a.copy.on || a.roomOpen() || a.at(pageSettings) || a.railFull() { return nil } agent, ok := a.rewinder() diff --git a/internal/tui3/rewindsheet_test.go b/internal/tui3/rewindsheet_test.go index a556143897..db696f7eb0 100644 --- a/internal/tui3/rewindsheet_test.go +++ b/internal/tui3/rewindsheet_test.go @@ -137,6 +137,7 @@ func TestTheTimelineRefusesWhereTheInlineModeDoes(t *testing.T) { name string hold func(*app) }{ + {"copy mode", func(a *app) { a.copy.on = true }}, {"the settings panel", func(a *app) { a.raisePlace(pageSettings) }}, {"the inline rewind", func(a *app) { runCmd(a.enterRewind()) }}, } { diff --git a/internal/tui3/room.go b/internal/tui3/room.go index 0648d45569..560fb4e2f5 100644 --- a/internal/tui3/room.go +++ b/internal/tui3/room.go @@ -1868,7 +1868,7 @@ func (a *app) roomKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { switch key := msg.String(); { case key == "ctrl+c", a.asking(), a.awaitingTask(), a.at(pageSettings), a.at(pageTasks), a.at(pageHome), a.deckShowing(), a.pick.open, - a.roster.open, a.welcome.open, a.menu.open, a.comp.open, + a.roster.open, a.copy.on, a.welcome.open, a.menu.open, a.comp.open, a.effPick.open: return nil, false } @@ -1964,6 +1964,13 @@ func (a *app) roomKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { a.cycleTaskEffort() return nil, true + case "ctrl+b": + // FREEZE THE ROOM, not the conversation. copymode.go snapshots the + // transcript's rows, which while a room is open are not the rows on + // screen — so the snapshot is taken here, from what is actually drawn. + a.freezeRoom() + return nil, true + case "pgup": a.roomScroll(-a.scrollPage()) return nil, true @@ -2061,6 +2068,54 @@ func (a *app) roomHint() string { return "" } +// freezeRoom hands copy mode the room's own rows. It is the same frozen viewport +// [app.enterCopy] builds — same struct, same cursor, same yank — over a +// different list, because what a person freezes must be what a person is +// reading. +// +// ONE WRINKLE, KNOWN AND SMALL: leaving copy mode rejoins the CONVERSATION's +// live edge ([app.exitCopy] sets stick), so a person who froze a room while the +// transcript was scrolled up loses that scroll. It is the one seam where the +// room does not leave the conversation untouched, and it is left alone because +// the alternative — a room-shaped exception inside copy mode — would put a +// second definition of "what is frozen" in a file whose whole point is that +// there is one. +func (a *app) freezeRoom() { + if a.copy.on || a.room == nil { + return + } + width := a.bodyWidth() + height := a.viewHeight() + // COPY OWNS THE PAGE BEFORE IT IS LAID OUT, so the room's transient + // activity and the blank belonging only to it never enter the snapshot + // (worklogo.go, #1384). + a.copy.on = true + a.room.dirty = true + rows := a.roomRows(width) + if len(rows) == 0 { + a.copy.on = false + a.room.dirty = true + return + } + snapshot := make([]string, 0, len(rows)) + stripped := make([]string, 0, len(rows)) + owner := make([]int, 0, len(rows)) + for _, r := range rows { + snapshot = append(snapshot, r.text) + stripped = append(stripped, ansi.Strip(r.text)) + // The index is into the ROOM's own list, which is the list this snapshot + // was taken from — that is all "a" needs it to be, since it only ever + // compares two rows of the same freeze (copymode.go's [app.copyBlock]). + owner = append(owner, r.entry) + } + top := a.roomOffsetFor(len(rows), height) + a.copy = copyMode{ + on: true, rows: snapshot, text: stripped, owner: owner, + at: min(top+height-1, len(rows)-1), top: top, mark: -1, + } + a.touch() +} + // ── moving between the conversation and the work ──────────────────────────── // // THE ARROWS ARE THE OTHER DOOR. The rail's click opens a room and esc leaves diff --git a/internal/tui3/roomscroll_test.go b/internal/tui3/roomscroll_test.go index e54145833b..e81068d2df 100644 --- a/internal/tui3/roomscroll_test.go +++ b/internal/tui3/roomscroll_test.go @@ -241,3 +241,54 @@ func TestAShorterFrameFoldsARoomToItsNewHeight(t *testing.T) { calls, shorter) } } + +// FREEZING A FOLDED ROOM STAYS IN BOUNDS. ctrl+b snapshots the room-sized rows, +// and every cursor the copy keys move lands inside them. +func TestFreezingAFoldedRoomKeepsTheCopyCursorInBounds(t *testing.T) { + a, _ := callsRoom(t) + width, height := a.bodyWidth(), a.viewHeight() + rows := a.roomRows(width) + + a.freezeRoom() + if !a.copy.on { + t.Fatal("ctrl+b did not freeze the room") + } + // THE FREEZE HOLDS EVERY ROW BUT THE SIGN OF LIFE. A running room's + // activity row and the one blank that exists only for it are laid out + // while the page is live and never while it is frozen (worklogo.go), so the + // copy is the page as copy mode draws it, and that is what the cursor must + // stay inside. + transient := 0 + for _, r := range rows { + if r.activity { + transient += 1 + spacingBlockRows + } + } + if len(a.copy.rows) != len(rows)-transient { + t.Fatalf("the freeze holds %d rows of a %d-row page whose %d rows are transient", + len(a.copy.rows), len(rows), transient) + } + inBounds := func(when string) { + t.Helper() + if a.copy.at < 0 || a.copy.at >= len(a.copy.rows) { + t.Fatalf("%s: the copy cursor is at %d of %d rows", when, a.copy.at, len(a.copy.rows)) + } + if a.copy.top < 0 || a.copy.top > max(len(a.copy.rows)-height, 0) { + t.Fatalf("%s: the copy window starts at %d of %d rows", when, a.copy.top, len(a.copy.rows)) + } + } + inBounds("on freeze") + // The room was stuck at its live edge, and so is the freeze: its window + // ends on the last frozen row, with the transient rows no longer counted. + if edge := max(len(a.copy.rows)-height, 0); a.copy.top != edge { + t.Fatalf("the freeze starts at %d, the live edge of its %d rows is %d", a.copy.top, len(a.copy.rows), edge) + } + for _, k := range []string{"home", "a", "end", "v", "pgup", "pgdown"} { + drive(t, a, key(k)) + inBounds("after " + k) + } + drive(t, a, key("esc")) + if a.copy.on { + t.Fatal("esc did not leave copy mode") + } +} diff --git a/internal/tui3/roomstatus_test.go b/internal/tui3/roomstatus_test.go index 13eff705c8..9aeda463b7 100644 --- a/internal/tui3/roomstatus_test.go +++ b/internal/tui3/roomstatus_test.go @@ -56,6 +56,11 @@ func TestTaskFooterKeepsPhaseAndKeyboardFeedback(t *testing.T) { if got, _ := a.stateSegment(); got != "awaiting your look" { t.Fatalf("task phase lost: %q", got) } + a.copy.on = true + if got, _ := a.stateSegment(); got != a.copyWord() { + t.Fatalf("copy keyboard feedback lost: %q", got) + } + a.copy.on = false a.roomNode().stopped = true if got, _ := a.stateSegment(); got != stoppingWord { t.Fatalf("stopped task still claims work: %q", got) diff --git a/internal/tui3/slotfilter_test.go b/internal/tui3/slotfilter_test.go index 096c47af57..8136e58f25 100644 --- a/internal/tui3/slotfilter_test.go +++ b/internal/tui3/slotfilter_test.go @@ -170,26 +170,32 @@ func TestASlotWithNoCandidateSaysSo(t *testing.T) { // ── 3. the hint slot follows the KEYBOARD ─────────────────────────────────── // THE ORDER IS input.go's ROUTING ORDER. A hint is only true if it names the -// keys the handler that reads first would take. +// keys the handler that reads first would take, and copy mode is the state this +// slot was most wrong about: every key means something else while the viewport +// is frozen, and the slot was drawing the input box's own two affordances. func TestTheHintSlotFollowsTheKeyboard(t *testing.T) { a := newTestApp(&fakeAgent{model: "openai/gpt-4.1-mini"}) - // The handed-over pointer leads, because the key that ends it is read - // above everything (input.go's [app.key]). + a.copy.on = true + if got := a.hintWord(); got != "v select · a block · y yank · esc" { + t.Fatalf("copy mode offered %q", got) + } + // And the handed-over pointer leads even copy mode, because the key that + // ends it is read above everything (input.go's [app.key]). a.released = true if got := a.hintWord(); got != "drag to select · any key ends it" { t.Fatalf("a handed-over pointer offered %q", got) } a.released = false - // The picker is read above the plain switch (input.go reads it before - // ctrl+c), so it wins the slot. + // The picker is read ABOVE copy mode (input.go reads it before ctrl+c), so + // it wins the slot when both are somehow up. a.pick.open = true // And the crew rides the end of it on any launch that has one (crew.go's // [app.crewHint]). if got := a.hintWord(); got != "enter switch · esc · crew "+config.DefaultCrew { t.Fatalf("an open picker offered %q", got) } - a.pick.open = false + a.pick.open, a.copy.on = false, false a.menu.open = true if got := a.hintWord(); got != "↑↓ · enter · esc" { diff --git a/internal/tui3/spellout.go b/internal/tui3/spellout.go index c243040ca2..acd92d7330 100644 --- a/internal/tui3/spellout.go +++ b/internal/tui3/spellout.go @@ -197,7 +197,7 @@ func (a *app) spellOffered() bool { if a.spellShowing() { return false } - if a.rew.on || a.roomOpen() { + if a.copy.on || a.rew.on || a.roomOpen() { return false } if _, ok := a.spellDoor(); !ok { diff --git a/internal/tui3/spellout_test.go b/internal/tui3/spellout_test.go index faca4a4e27..318dcebcad 100644 --- a/internal/tui3/spellout_test.go +++ b/internal/tui3/spellout_test.go @@ -319,11 +319,16 @@ func TestABuildWithNoExpanderNeverOffersTheChord(t *testing.T) { } } -// The block is never up while a turn is running — it belongs to the draft at -// rest, and a running answer took the keys with it. -func TestTheBlockIsNotOfferedWhileATurnRuns(t *testing.T) { +// The block is never up while the pointer is somewhere else on the surface — it +// belongs to the draft, and a page that replaced the draft took it with them. +func TestTheBlockIsNotOfferedInCopyMode(t *testing.T) { a, _ := spellLab(t) typeDraft(t, a, "build me a login page") + a.copy.on = true + if a.spellOffered() { + t.Fatal("the chord was offered while the viewport was frozen") + } + a.copy.on = false a.state = stateWorking if a.spellOffered() { t.Fatal("the chord was offered while a turn was running") diff --git a/internal/tui3/steer.go b/internal/tui3/steer.go index 0b1f8ec560..5d2c484173 100644 --- a/internal/tui3/steer.go +++ b/internal/tui3/steer.go @@ -200,7 +200,7 @@ func (a *app) steerAvailable() bool { // the keyboard outright, and the rail holds it while the roster is up. Every // one of these is read above the plain switch in [app.key], so the guard is // here for the HINT's sake as much as the key's. - return !a.roomOpen() && !a.rew.on && !a.railHold + return !a.roomOpen() && !a.copy.on && !a.rew.on && !a.railHold } // steerOffered reports whether plain enter may be named as a steer — which is @@ -453,7 +453,7 @@ func (a *app) runSendOffered() bool { if a.state != stateWorking || a.input.empty() && len(a.chips) == 0 { return false } - return !a.roomOpen() && !a.rew.on && !a.railHold + return !a.roomOpen() && !a.copy.on && !a.rew.on && !a.railHold } // runHint is the one line while a turn runs. Its order follows the hand across diff --git a/internal/tui3/steer_test.go b/internal/tui3/steer_test.go index e1d92f5cde..61826a163c 100644 --- a/internal/tui3/steer_test.go +++ b/internal/tui3/steer_test.go @@ -548,6 +548,33 @@ func TestAFallenThroughCorrectionIsOnTheScreenExactlyOnce(t *testing.T) { } } +// ── the overlays above ────────────────────────────────────────────────────── + +// AN OVERLAY THAT HAS TAKEN THE KEYBOARD KEEPS BOTH KEYS. Copy mode is read +// above the plain switch, and while it is up the surface is a reader: a chord +// that reached past it would send a sentence out of a box nobody is looking at. +func TestAnOverlayAboveKeepsBothSteerKeys(t *testing.T) { + a, agent := steerableTurn(t, "reading the tree. ") + parkLine(t, a, "do much more of a deep research please") + a.input.setText("no, the other file") + a.enterCopy() + if !a.copy.on { + t.Fatal("copy mode did not open") + } + + drive(t, a, wirePress(t, "\x1b[13;9u", steerKeySuper), key("right")) + if len(agent.steered) != 0 { + t.Fatalf("a key reached past copy mode and steered: %q", agent.steered) + } + if len(a.parks) != 1 { + t.Fatalf("a key reached past copy mode and moved the queue: %+v", a.parks) + } + // And the slot names neither, because neither would do anything. + if got := a.hintWord(); strings.Contains(got, parkKey) || strings.Contains(got, steerSendWord) { + t.Fatalf("the hint named the steer while copy mode held the keyboard: %q", got) + } +} + // ── the two lines that teach it ───────────────────────────────────────────── // H2 and H3: the one running-turn line names every deliverable key in its fixed diff --git a/internal/tui3/stop.go b/internal/tui3/stop.go index efbd962b32..b5077be5e9 100644 --- a/internal/tui3/stop.go +++ b/internal/tui3/stop.go @@ -601,7 +601,7 @@ func (a *app) stopKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { case key == "ctrl+c", a.asking(), a.awaitingTask(), a.guarding(), a.taskSheet.planOn, a.railPlanPending.id != "", a.at(pageSettings), a.at(pageTasks), a.at(pageHome), a.deckShowing(), a.pick.open, - a.roster.open, a.welcome.open, a.menu.open, a.comp.open, + a.roster.open, a.copy.on, a.welcome.open, a.menu.open, a.comp.open, a.rew.on, a.rewSheet.open: return nil, false } @@ -643,7 +643,7 @@ const stopRaiseKey = "x" // took it. Two targets, in the order they are stacked on screen: the card's own // answers while it is up, and the ✕ in the room's header. func (a *app) stopPress(x, y int) bool { - if a.rew.on { + if a.copy.on || a.rew.on { return false } if a.stopping() { diff --git a/internal/tui3/task.go b/internal/tui3/task.go index cecf55ab74..4ab2078565 100644 --- a/internal/tui3/task.go +++ b/internal/tui3/task.go @@ -3854,7 +3854,7 @@ func (a *app) railKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { key := msg.String() switch { case key == "ctrl+c", a.asking(), a.awaitingTask(), - a.at(pageSettings), a.at(pageTasks), a.at(pageHome), a.pick.open, + a.at(pageSettings), a.at(pageTasks), a.at(pageHome), a.pick.open, a.copy.on, a.welcome.open, a.menu.open, a.comp.open, a.effPick.open: return nil, false } diff --git a/internal/tui3/taskstrip.go b/internal/tui3/taskstrip.go index 7dab76b40c..edb0bf0dee 100644 --- a/internal/tui3/taskstrip.go +++ b/internal/tui3/taskstrip.go @@ -504,7 +504,7 @@ func (a *app) stripTitle(node *taskNode, title string) string { // reading them first would be reading where the chips were drawn on the frame // before this one. func (a *app) stripPress(x, y int) (tea.Cmd, bool) { - if a.at(pageSettings) || a.welcome.open { + if a.at(pageSettings) || a.copy.on || a.welcome.open { return nil, false } width, _ := a.size() @@ -557,7 +557,7 @@ func (a *app) stripPress(x, y int) (tea.Cmd, bool) { // are not in disagreement: the row eats the miss so it cannot fall through to the // conversation, and a gap that brightened would be claiming to be a door. func (a *app) stripHoverAt(x, y int) (hoverAt, bool) { - if a.at(pageSettings) || a.welcome.open || y != a.headHeight() { + if a.at(pageSettings) || a.copy.on || a.welcome.open || y != a.headHeight() { return hoverAt{}, false } width, _ := a.size() diff --git a/internal/tui3/taskview.go b/internal/tui3/taskview.go index 88b5dbde17..51bb949915 100644 --- a/internal/tui3/taskview.go +++ b/internal/tui3/taskview.go @@ -47,8 +47,8 @@ import ( // // ctrl+. IS THE LAST OBVIOUS CHORD AND IT IS SPENT DELIBERATELY. Every // ctrl+<letter> this surface could reach for is taken — the readline edits the -// message box answers without looking, the roster's alt+t, the column's ctrl+g -// — and the four letters that are free are documented as NOT +// message box answers without looking, the roster's alt+t, the column's ctrl+g, +// copy mode's ctrl+b — and the four letters that are free are documented as NOT // BOUND, which is a promise a person has read. What is left is the punctuation // pair, and the pair is the point: ctrl+, opens the settings panel and ctrl+. // opens this one, two adjacent keys for the two fullscreen pages. A terminal diff --git a/internal/tui3/tui3_test.go b/internal/tui3/tui3_test.go index 54341a6846..15db1a8c74 100644 --- a/internal/tui3/tui3_test.go +++ b/internal/tui3/tui3_test.go @@ -825,7 +825,7 @@ func newTestAppWithProfile(profileDir string, agent Agent) *app { // AND IT PINS THE TERMINAL, which is the second and third pin in one // table, for exactly the reason the palette is pinned below: [newApp] // reads the environment to decide whether a clipboard write needs the - // multiplexer's passthrough wrapper (clipboard.go), how often the frame + // multiplexer's passthrough wrapper (copymode.go), how often the frame // clock turns over a link (link.go), and whether a path may be written // as an OSC 8 link at all (pathlink.go) — so a suite run inside tmux got // the wrapped yank, a suite run over ssh stepped every animation three diff --git a/internal/tui3/view.go b/internal/tui3/view.go index e7c41500a8..c93551424b 100644 --- a/internal/tui3/view.go +++ b/internal/tui3/view.go @@ -1090,6 +1090,9 @@ func (a *app) bodyRows(width, height int) ([]row, int) { // row is what says which page it belongs to. return []row{{text: a.pal.dim(fit(startTinyWord, width)), entry: -1}}, max(0, height-1) } + if a.copy.on { + return a.copyRows(width, height) + } // AND A QUESTION OPENED OUT INTO ITS OWN PAGE IS THE FOURTH ANSWER, on the // room's own terms and above it (questionroom.go): a question is drawn over // whatever it was raised about, and a node's page is one of the things it can diff --git a/internal/tui3/worklogo.go b/internal/tui3/worklogo.go index 657d91459a..0a8f531f7a 100644 --- a/internal/tui3/worklogo.go +++ b/internal/tui3/worklogo.go @@ -14,7 +14,7 @@ import ( // and an approval never wears movement that implies work can continue unaided. func (a *app) workLogoVisible() bool { return a.workActivity.Started() && !a.turnBegan.IsZero() && a.state == stateWorking && - a.showing() == nil && len(a.questionOpen()) == 0 && !a.asking() && a.room == nil && !a.linear && !a.pal.linear && + a.showing() == nil && len(a.questionOpen()) == 0 && !a.asking() && !a.copy.on && a.room == nil && !a.linear && !a.pal.linear && !a.pal.ascii && a.pal.profile >= tokens.ANSI256 && a.width >= 48 && a.height >= 20 && a.turnAnchor() >= 0 } @@ -82,7 +82,7 @@ func (a *app) activityMark(activity tokens.WorkActivity) string { // A held, finished, failed or disconnected task cannot advertise progress. func (a *app) roomWorkLogoVisible() bool { if a.room == nil || !a.room.running() || !a.room.workActivity.Started() || a.showing() != nil || len(a.questionOpen()) > 0 || - a.linear || a.pal.linear || a.pal.ascii || a.pal.profile < tokens.ANSI256 || + a.copy.on || a.linear || a.pal.linear || a.pal.ascii || a.pal.profile < tokens.ANSI256 || a.width < 48 || a.height < 20 { return false } diff --git a/internal/tui3/worklogo_test.go b/internal/tui3/worklogo_test.go index 00f110bd04..dd2637aa0e 100644 --- a/internal/tui3/worklogo_test.go +++ b/internal/tui3/worklogo_test.go @@ -1,6 +1,7 @@ package tui3 import ( + "slices" "strings" "testing" "time" @@ -114,11 +115,94 @@ func TestWorkingLogoFallbacksAndTransientRows(t *testing.T) { } } -// C1 AND C2 WENT WITH COPY MODE. They said a frozen snapshot holds the page -// without the transient activity row, and there is no frozen snapshot any -// more: `ctrl+b`, the copy keys, `/copy` and `freezeRoom` are all deleted, and -// copying is the mouse (clipboard.go). Everything else about the activity row -// is unchanged, and the tests below still hold it. +// C1 says a copy snapshot is the exact page without the transient working row +// or the blank that row alone introduced, and thawing restores live movement. +func TestCopyModeFreezesThePageWithoutTheWorkingLogo(t *testing.T) { + a := workLogoApp(t) + width := a.bodyWidth() + if got := workingActivityRows(a.visible(width)); len(got) != 1 { + t.Fatalf("live page has activity rows %v, want exactly one", got) + } + caption := a.workActivity.Caption() + + wantApp := workLogoApp(t) + wantApp.workActivity = tokens.WorkActivity{} + want := workLogoRowTexts(wantApp.layout(width)) + + a.enterCopy() + if !a.copy.on { + t.Fatal("copy mode did not open") + } + plainCopy := ansi.Strip(strings.Join(a.copy.rows, "\n")) + if strings.Contains(plainCopy, caption) { + t.Fatalf("copy snapshot retained the activity caption %q", caption) + } + if strings.ContainsAny(plainCopy, "●•·˙") { + t.Fatalf("copy snapshot retained a working-logo mark:\n%s", plainCopy) + } + if !slices.Equal(a.copy.rows, want) { + t.Fatalf("copy snapshot differs from the page before activity:\n got %q\nwant %q", a.copy.rows, want) + } + + a.exitCopy() + if got := workingActivityRows(a.visible(width)); len(got) != 1 { + t.Fatalf("thawed page has activity rows %v, want exactly one", got) + } +} + +// C2 gives task and adaptive-run pages the same copy law through their shared +// room freeze door. +func TestCopyModeFreezesWorkPagesWithoutTheWorkingLogo(t *testing.T) { + for _, tc := range []struct { + name string + app func(*testing.T) *app + }{ + {name: "task", app: func(t *testing.T) *app { + a, _ := roomModelApp(t, "task-model") + a.width, a.height = 100, 40 + a.pal = newPalette(tokens.TrueColor, false) + a.room.entries = []entry{{kind: entryUser, text: "Ship the parser fix", turn: 1}} + a.room.turn, a.room.readingRestored, a.room.dirty = 1, true, true + return a + }}, + {name: "adaptive run", app: func(t *testing.T) *app { + a, _ := orchApp(t, orchestrate.Snapshot{}) + a.width, a.height = 100, 40 + a.pal = newPalette(tokens.TrueColor, false) + a.orchOf().known = true + a.room.dirty = true + return a + }}, + } { + t.Run(tc.name, func(t *testing.T) { + a := tc.app(t) + width := a.bodyWidth() + activity := a.room.workActivity + a.room.workActivity = tokens.WorkActivity{} + a.room.dirty = true + want := workLogoRowTexts(a.roomRows(width)) + a.room.workActivity = activity + a.room.dirty = true + if got := workingActivityRows(a.roomRows(width)); len(got) != 1 { + t.Fatalf("live page has activity rows %v, want exactly one", got) + } + + a.freezeRoom() + if !a.copy.on { + t.Fatal("copy mode did not open") + } + if !slices.Equal(a.copy.rows, want) { + t.Fatalf("copy snapshot differs from the page before activity:\n got %q\nwant %q", a.copy.rows, want) + } + + a.exitCopy() + if got := workingActivityRows(a.roomRows(width)); len(got) != 1 { + t.Fatalf("thawed page has activity rows %v, want exactly one", got) + } + }) + } +} + // C3 keeps adopted or self-started work on the compact waiting treatment until // its running turn has a question of its own to anchor. func TestAWorkingTurnWithoutItsOwnQuestionKeepsTheWaitingText(t *testing.T) { From ae67d1f2bc63aed4f541f429ef02d48cf4d6f1b7 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Wed, 23 Sep 2026 08:50:40 -0400 Subject: [PATCH 31/39] chat: the rewind tip names both doors, the chord and the command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1388 respelled this row from `/rewind takes back an earlier message` to `esc esc takes back the last message`, and it arrived here in that merge rather than by anybody's choice on this branch. The owner read it back and did not recognise it. Either spelling teaches half of it. `esc esc` is the half nobody discovers. `/rewind` is the half that survives: a person who read only the chord has no word to type into `/`, no word to ask the manual, and nothing to search for a week later. So the row names both: esc esc or /rewind takes back an earlier message `an earlier message` rather than `the last` because both doors walk back further than one turn — the chord through the drawn blocks, the command through the whole conversation. Same id, same gesture; eventRewound already fires from both doors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .../unreleased/1389-one-tip-list-two-boxes.md | 2 +- internal/manual/chat/hints-and-tips.md | 15 ++++++++------- internal/tui3/notice.go | 10 +++++++++- 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/docs/changes/unreleased/1389-one-tip-list-two-boxes.md b/docs/changes/unreleased/1389-one-tip-list-two-boxes.md index 91446090c0..92a8ee440a 100644 --- a/docs/changes/unreleased/1389-one-tip-list-two-boxes.md +++ b/docs/changes/unreleased/1389-one-tip-list-two-boxes.md @@ -14,7 +14,7 @@ invalidates: - "/manual printed the manual's pages as written with no model call. It is a turn now: the question goes to the model, told to answer from the manual tool and name the page. The as-written reading is still `codeaf manual` at the terminal." - "The Workspace tab's row was `ui.hints` meaning shown. The row reads `disable hints`, off by default; the persisted key keeps its bytes and internal/config inverts once on the way in and out." - "A showing was every visible change of hands of a tip row. On home a showing is now a tip that stood twenty seconds where it could be seen; in a conversation it is one session, counted when the slot takes the tip. A ledger written under the old rule is read once with the rows that rule spent forgiven, and the tips a gesture retired stay retired." - - "The rewind tip read `/rewind takes back an earlier message`. It reads `esc esc takes back the last message`, because #1388 gave the chord back: the row teaches the half you cannot discover, and either door retires it." + - "The rewind tip read `/rewind takes back an earlier message`, and for one day `esc esc takes back the last message`. It names both doors — `esc esc or /rewind takes back an earlier message` — because #1388 gave the chord back and a row teaching only the chord leaves nobody a word to type into `/` or ask the manual about. Either door retires it." - "For one day this branch taught esc as the door home, after the surface stopped treating two spaces that way on 2026-09-17. #1388 restored the gesture, so TWO SPACES IN AN EMPTY BOX open home and esc is the interrupt, the layer peel and the arming half of rewind. Every manual passage and every test here says so." --- There is one table of tips and there are two boxes, and the wave ends with the diff --git a/internal/manual/chat/hints-and-tips.md b/internal/manual/chat/hints-and-tips.md index 9870d07b66..ba9f6d68a2 100644 --- a/internal/manual/chat/hints-and-tips.md +++ b/internal/manual/chat/hints-and-tips.md @@ -7,9 +7,9 @@ work right now: `esc interrupt` while an answer is coming, `y allow · n deny · while codeaf is asking you something, `space space home` when there is a home to go to, `/ commands` when nothing else is true. Once you have used codeaf a little, that idle line sometimes carries a **tip** instead — one sentence naming a key or a command you have not -used yet, and what it does, for example `esc esc takes back the last message` or `/files -finds everything made for you`. It reads the way every hint on this surface does: the key -or the command first, then what it does. +used yet, and what it does, for example `esc esc or /rewind takes back an earlier message` +or `/files finds everything made for you`. It reads the way every hint on this surface +does: the key or the command first, then what it does. **In a conversation the tip is the keys row's lowest rung.** It takes that row from the rest state — the line a newcomer reads when nothing is happening — and every state with @@ -108,10 +108,11 @@ build if the two disagree), so a tip you saw is on it word for word. context window. Retired when a `/compact` finishes. - `/cost says what this conversation has spent` — once the conversation has spent about ten cents. Retired when you run `/cost`. -- `esc esc takes back the last message` — after an answer of about 1,500 characters or - more. Retired the first time a rewind lands, from `esc esc` or from `/rewind`. It named - `/rewind` instead until 2026-09-23, while `esc esc` was not a gesture; the chord is the - half you cannot discover, so the chord is what the row teaches. +- `esc esc or /rewind takes back an earlier message` — after an answer of about 1,500 + characters or more. Retired the first time a rewind lands, by either door. It is the one + row that names a chord and a command for the same thing, on purpose: `esc esc` is the + half nobody discovers, and `/rewind` is the half you can type into `/` or ask the manual + about a week later. - `/files finds everything made for you` — after the first export writes a file. Retired when you run `/files`. - `/resume opens an earlier conversation` — when you start in a directory that already has diff --git a/internal/tui3/notice.go b/internal/tui3/notice.go index 17a06d3b7a..2c04291de9 100644 --- a/internal/tui3/notice.go +++ b/internal/tui3/notice.go @@ -342,9 +342,17 @@ var notices = []notice{ // [eventTaskPageOpened] are still fired at their seams: nothing in the // table waits on either one now, and a later row may. { + // BOTH DOORS, BECAUSE THEY ARE ONE THING. The row said `/rewind takes + // back an earlier message` until #1388 gave `esc esc` back and + // respelled it as the chord alone. Either spelling teaches half of it: + // the chord is the half nobody discovers, and the command is the half + // that makes the chord findable again tomorrow — a person who reads + // only `esc esc` has no word to type into `/` or to ask the manual + // about. One row names both and retires on either (eventRewound fires + // from both doors, rewind.go's [app.rewindLand]). id: "rewind-after-long-answer", slot: slotHint, armed: func(a *app) bool { return a.lastAnswerRunes() >= longAnswerRunes }, - text: "esc esc takes back the last message", + text: "esc esc or /rewind takes back an earlier message", retire: eventRewound, }, { From 95ed821110e5697c2267d9e3528286fa32fc8684 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Wed, 23 Sep 2026 09:02:17 -0400 Subject: [PATCH 32/39] chat: seven tips in the owner's words MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner's read of the list, row by row. Every id and every retiring gesture is unchanged, so nobody who has already used one of these is told about it again. /files finds everything made for you → /files finds files codeaf wrote for you /standing turns a sentence into a rule work must follow → /standing turns a message into a rule work must follow /task starts work you can walk away from → /task starts a single-shot task on the side ctrl+shift+t reopens the last closed conversation tab → ctrl+shift+t reopens the last conversation tab /project sets the project folder for the next conversation → /project sets the project folder for a new conversation /model lists every model, /model <slug> switches at once → /model lets you see and choose models and providers using enter steers conversations · use ctrl+q to queue → using enter steers conversations · use ctrl+q to queue messages THE COMMAND IS `/files` AND THE ROW SAYS SO. The owner wrote `/file`. The table has no such command and no such alias (commands.go), and a tip naming a word that answers `there is no command called /file` teaches a dead end — which is what the `/folder` row was taken off for. `on the side` rather than `one the side`, under the owner's standing order to fix grammar without asking. The manual follows on every line, including the three quoted frames on the home and places pages and the two examples in the hints page's own prose. notice_test.go's fixture tip follows /files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- internal/manual/chat/hints-and-tips.md | 22 +++++++++++----------- internal/manual/chat/home.md | 2 +- internal/manual/chat/places.md | 2 +- internal/tui3/notice.go | 18 +++++++++--------- internal/tui3/notice_test.go | 2 +- 5 files changed, 23 insertions(+), 23 deletions(-) diff --git a/internal/manual/chat/hints-and-tips.md b/internal/manual/chat/hints-and-tips.md index ba9f6d68a2..8a165ec533 100644 --- a/internal/manual/chat/hints-and-tips.md +++ b/internal/manual/chat/hints-and-tips.md @@ -8,7 +8,7 @@ while codeaf is asking you something, `space space home` when there is a home to `/ commands` when nothing else is true. Once you have used codeaf a little, that idle line sometimes carries a **tip** instead — one sentence naming a key or a command you have not used yet, and what it does, for example `esc esc or /rewind takes back an earlier message` -or `/files finds everything made for you`. It reads the way every hint on this surface +or `/files finds files codeaf wrote for you`. It reads the way every hint on this surface does: the key or the command first, then what it does. **In a conversation the tip is the keys row's lowest rung.** It takes that row from the @@ -39,7 +39,7 @@ On a Mac the row says `opt` where the table below says `alt`, exactly as the key On home the tip is the dim row **directly above the rule** over the message box — the blank that separates the list from the rule, with one sentence written into its right end, led -by a bulb: `💡 /project sets the project folder for the next conversation ✕`. It is drawn only +by a bulb: `💡 /project sets the project folder for a new conversation ✕`. It is drawn only while the box is empty and nothing else is up — a letter in the box, the `/` list, the `@` list or a reply being read all take the row back — and it moves on to the next tip that is true for you on every road home (two spaces in an empty box, `/home`, `alt+1`, `tab`), in a @@ -54,10 +54,10 @@ put away nothing. Every tip is earned and then spent. It appears the first time it becomes relevant — the first task you start, the first long answer, the first time a conversation passes half its context window, or simply the first time home is open — and it goes away for good the first -time you do the thing it names. Run `/files` once and `/files finds everything made for +time you do the thing it names. Run `/files` once and `/files finds files codeaf wrote for you` never comes back; run `/compact` once and the compact tip is retired. A tip retired from either box is retired from both: opening the model list on home retires -`/model lists every model` in every conversation as well. +`/model lets you see and choose models and providers` in every conversation as well. A tip you never act on is not shown forever either. Once a tip has been **shown six times** it is taken as read and retires by itself — and the two rows count a showing differently, @@ -113,20 +113,20 @@ build if the two disagree), so a tip you saw is on it word for word. row that names a chord and a command for the same thing, on purpose: `esc esc` is the half nobody discovers, and `/rewind` is the half you can type into `/` or ask the manual about a week later. -- `/files finds everything made for you` — after the first export writes a file. Retired +- `/files finds files codeaf wrote for you` — after the first export writes a file. Retired when you run `/files`. - `/resume opens an earlier conversation` — when you start in a directory that already has a conversation. Retired when you run `/resume`. -- `/standing turns a sentence into a rule work must follow` — once this directory has three or more earlier +- `/standing turns a message into a rule work must follow` — once this directory has three or more earlier conversations. Retired when a standing order is made or the standing page opened. -- `/task starts work you can walk away from` — after the first exchange. Retired when +- `/task starts a single-shot task on the side` — after the first exchange. Retired when `/task` is typed, bare or with a brief. - `ctrl+enter makes your message a rule instead of a request` — retired when a standing order is made or the standing page opened. It teaches the same door as the `/standing` row above and retires with it, so the two say a rule in the same words. - `/manual answers any question about codeaf` — retired when `/manual` is typed, bare or with a question. -- `ctrl+shift+t reopens the last closed conversation tab` — retired the first time the chord is +- `ctrl+shift+t reopens the last conversation tab` — retired the first time the chord is pressed, on a terminal that can send it. **Files and context** @@ -135,7 +135,7 @@ build if the two disagree), so a tip you saw is on it word for word. opens. - `/attach sends a file or folder with your message` — retired when a file or a folder goes on by path, or the browser opens. -- `/project sets the project folder for the next conversation` — on home only, since that is +- `/project sets the project folder for a new conversation` — on home only, since that is the only screen `/project` works on. Retired when `/project` takes a folder, by a path after it or on the browser it opens. - `/export writes the current conversation to a file` — after two exchanges. Retired when @@ -143,7 +143,7 @@ build if the two disagree), so a tip you saw is on it word for word. **Models, thinking and cost** -- `/model lists every model, /model <slug> switches at once` — retired when the model list +- `/model lets you see and choose models and providers` — retired when the model list opens, over a conversation or over home's draft. - `/crew sets the models codeaf uses on its own behalf` — retired when `/crew` answers, bare or with a preset. @@ -151,7 +151,7 @@ build if the two disagree), so a tip you saw is on it word for word. **Steering a running answer** -- `using enter steers conversations · use ctrl+q to queue` — after the first +- `using enter steers conversations · use ctrl+q to queue messages` — after the first exchange. Retired the first time you queue a message. It was two rows until 2026-09-22 — one for the steer and one for the queue — and the owner folded them into one. diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index 2b800da767..98357953f7 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -1402,7 +1402,7 @@ home until 2026-09-22, when the pin became a command of its own. The one dim line directly above the rule over home's box is a **tip**: one sentence naming a key or a command you have not used yet, and what it does — `/project sets the project folder -for the next conversation`, `ctrl+t starts a fresh chat in this project`. It is drawn only while +for a new conversation`, `ctrl+t starts a fresh chat in this project`. It is drawn only while the box is empty and nothing else is up, it moves on to the next tip every time you come to home and every two minutes at rest, and each tip goes away for good the first time you do what it names. It sits at the right, led by a bulb and closed by a small cross: clicking it diff --git a/internal/manual/chat/places.md b/internal/manual/chat/places.md index f72683b456..cca79f6270 100644 --- a/internal/manual/chat/places.md +++ b/internal/manual/chat/places.md @@ -198,7 +198,7 @@ The line over home's box is the same shape as the line over a conversation's own box: ``` - 💡 /project sets the project folder for the next conversation ✕ + 💡 /project sets the project folder for a new conversation ✕ ─ glm-5.3-flash:auto · ◇ asks ────────────────────────────────────────────────────── › type to search or start something new alt+p project · alt+e effort · alt+a approvals · / commands project: ~/src/parser diff --git a/internal/tui3/notice.go b/internal/tui3/notice.go index 2c04291de9..e538a5613f 100644 --- a/internal/tui3/notice.go +++ b/internal/tui3/notice.go @@ -17,8 +17,8 @@ import ( // A surface learns you by what you have already done, and this file is where it // keeps what it has told you. Two kinds of thing live here at launch: // -// - EARNED HINTS. One dim line beside the box — `/files finds everything -// made for you` — on the row directly above the rule over home's box, and +// - EARNED HINTS. One dim line beside the box — `/files finds files codeaf +// wrote for you` — on the row directly above the rule over home's box, and // on the lowest rung of a conversation's keys row. A tip RETIRES FOR GOOD // the first time the gesture it teaches is used (the files place opened), // or after it has been shown [noticeShownDefault] times without being @@ -358,7 +358,7 @@ var notices = []notice{ { id: "files-after-first-deliverable", slot: slotHint, armed: func(a *app) bool { return a.notices.seen[eventDeliverableMade] }, - text: "/files finds everything made for you", + text: "/files finds files codeaf wrote for you", retire: eventFilesOpened, }, { @@ -379,7 +379,7 @@ var notices = []notice{ // standing order is a CONDITION the work has to honour — it rides into // a task's brief under its own heading and the worker reports when it // cannot meet one — and a memory is a fact carried forward. - text: "/standing turns a sentence into a rule work must follow", + text: "/standing turns a message into a rule work must follow", retire: eventStandingOpened, }, // ── starting work ─────────────────────────────────────────────────────── @@ -391,7 +391,7 @@ var notices = []notice{ { id: "task-in-chat", slot: slotHint, armed: spoken, - text: "/task starts work you can walk away from", + text: "/task starts a single-shot task on the side", retire: eventTaskTyped, }, { @@ -416,7 +416,7 @@ var notices = []notice{ { id: "reopen-tab", slot: slotHint, armed: ready, - text: "ctrl+shift+t reopens the last closed conversation tab", + text: "ctrl+shift+t reopens the last conversation tab", retire: eventTabReopened, }, // ── files and context ─────────────────────────────────────────────────── @@ -445,7 +445,7 @@ var notices = []notice{ // there by pointing back at home. id: "pick-a-project", slot: slotHint, armed: onHome, - text: "/project sets the project folder for the next conversation", + text: "/project sets the project folder for a new conversation", retire: eventProjectSet, }, { @@ -458,7 +458,7 @@ var notices = []notice{ { id: "model-list", slot: slotHint, armed: ready, - text: "/model lists every model, /model <slug> switches at once", + text: "/model lets you see and choose models and providers", retire: eventModelListOpened, }, { @@ -482,7 +482,7 @@ var notices = []notice{ // not been told the other half ([notice.id]). id: "steer-and-queue", slot: slotHint, armed: spoken, - text: "using enter steers conversations · use ctrl+q to queue", + text: "using enter steers conversations · use ctrl+q to queue messages", retire: eventQueued, }, // ── moving around ─────────────────────────────────────────────────────── diff --git a/internal/tui3/notice_test.go b/internal/tui3/notice_test.go index 619c20f1bf..6d31d69259 100644 --- a/internal/tui3/notice_test.go +++ b/internal/tui3/notice_test.go @@ -515,7 +515,7 @@ func startTask(t *testing.T, a *app) { // retired by a different gesture — so they need a row with an event on both // ends. It was `ctrl+. sees every task this project has run` until 2026-09-22, // when the owner took that row off the table. -const deliverTip = "/files finds everything made for you" +const deliverTip = "/files finds files codeaf wrote for you" // makeDeliverable is an export landing on disk, as the loop sees it: the first // thing written for the person, which is what arms [deliverTip]. From 79fc218fb73fb698aae1a49dc49644dceed27f25 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Wed, 23 Sep 2026 09:07:39 -0400 Subject: [PATCH 33/39] chat: a taken /project says nothing, and home's keys row stays up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner met it: after `/project <path>` home's keys row was gone, replaced by `project · ~/Code/deep-swe`, and it stayed that way until they pressed a key. Home's sentence is the REFUSAL SLOT. It is drawn IN PLACE OF the keys row (homephone.go's [app.homeBar]: "a refusal outranks the bar") and stands until the next keystroke clears it. That is right for a refusal — it is a fact about a door somebody just tried, and nothing else on the screen carries it. It is wrong for a success, and this one was wrong twice over: - it hid the keys row for as long as it stood, which is what the owner saw and had no way to read as anything but a stuck screen; - the row it was covering already said the same thing. The pin is a string on this window, so `project: <path>` is at the right end of those keys from the very next frame. The comment two lines above the call said exactly that and the call contradicted it. The browser road has never said anything, for this reason (folderact.go's [app.targetFolderConfirm]), so the typed road was also the odd one out. Both refusals still speak: `no folder there · <path>`, and the connection one over --host. The probe that found it: home's foot after `/project` read ` → options · project · /var/…` and after one keystroke ` → options · alt+p project · / commands project: /var/…`. It now reads the second on both sides of the command. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- internal/manual/chat/choosing-a-folder.md | 6 ++++-- internal/manual/chat/commands.md | 2 +- internal/manual/chat/home.md | 7 ++++--- internal/tui3/homefate_test.go | 18 ++++++++++++------ internal/tui3/projectcmd.go | 20 +++++++++++++------- 5 files changed, 34 insertions(+), 19 deletions(-) diff --git a/internal/manual/chat/choosing-a-folder.md b/internal/manual/chat/choosing-a-folder.md index 7bb6a908c3..d7499f0cac 100644 --- a/internal/manual/chat/choosing-a-folder.md +++ b/internal/manual/chat/choosing-a-folder.md @@ -117,8 +117,10 @@ forms: ``` A path that is not a folder on this machine is refused by name — `no folder there · -~/src/parsr` — and nothing is pinned. A folder that is there is taken at once: home says -`project · ~/src/parser` and the keys row under the box changes on the very next frame. +~/src/parsr` — and nothing is pinned. A folder that is there is taken at once, and **home says nothing about it**: the keys row +under the box changes on the very next frame, and `project: ~/src/parser` at its right end +is the answer. (It used to also write `project · ~/src/parser` over the keys themselves, +which hid the row that was already saying it until the next keystroke.) Over `--host` neither form works: this machine's directory cannot be the far conversation's folder, so it says the refusal in the section above. diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index 068fa8ca4c..3c61954010 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -419,7 +419,7 @@ path after it, it takes the path and opens nothing: ``` /project the browser -/project ~/src/parser pinned at once · home says `project · ~/src/parser` +/project ~/src/parser pinned at once · the keys row says project: ~/src/parser ``` A path that is not a directory on this machine is refused by name — `no folder there · diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index 98357953f7..e99da60a8f 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -1282,7 +1282,7 @@ one behind your back. This is every fate, in the words the drop-up draws them in | The words on the row | What you type | What happens | | --- | --- | --- | | **`pins the next conversation's model`** | `/model` · `/model <slug>` | The list opens in home's own body; the pinned model appears on the rule above the box. Nothing behind home is touched. | -| **`next conversation's folder`** | `/project` · `/project <path>` | Bare, opens the folder browser **aimed at the next conversation**; picking a folder pins it, with no duplicate footer message. With a path, pins that folder at once and opens nothing, saying `project · ~/src/parser`. Either way `project: ~/src/parser` at the right of the keys row shows the selection. | +| **`next conversation's folder`** | `/project` · `/project <path>` | Bare, opens the folder browser **aimed at the next conversation**; picking a folder pins it, with no duplicate footer message. With a path, pins that folder at once, opens nothing and says nothing. Either way `project: ~/src/parser` at the right of the keys row shows the selection, and neither road writes a second sentence over the keys. | | **`opens the page`** | `/settings` `/set` `/config` · `/home` · `/search` · `/spend` · `/standing` · `/memory` `/memories` · `/history` · `/task` (bare) | A place replaces a place, exactly as before. | | **`this list is /resume`** | `/resume` `/sessions` | Says `this list is /resume · enter opens a row` — home *is* that list. | | **`onto home's tray`** | `/attach <path>` | The file — or picture — rides on home's own tray into the conversation you open next. Home says `attached · notes.md · rides with the next conversation`. A bare `/attach` opens the browser aimed at the next conversation's folder, and a file chosen there lands on the tray. | @@ -1394,8 +1394,9 @@ Typed bare on home it opens the folder browser with the title `the next conversa folder`. Its action row reads `open the next conversation in · ~/src/parser`, and `enter` there pins the target and drops you back on home with the rule already changed. Nothing on that sheet touches the conversation behind home. `/project ~/src/parser` skips the browser -and pins the folder straight away, saying `project · ~/src/parser`; a path that is not a -folder is refused as `no folder there · <path>` and nothing changes. This was `/folder` on +and pins the folder straight away without a word — the keys row's own `project: <path>` is +the answer; a path that is not a folder is refused as `no folder there · <path>` and +nothing changes. This was `/folder` on home until 2026-09-22, when the pin became a command of its own. ## The dim sentence above the rule on home — what is that tip over the box, why did it change diff --git a/internal/tui3/homefate_test.go b/internal/tui3/homefate_test.go index 8eb95fcac1..8ae8d6fc12 100644 --- a/internal/tui3/homefate_test.go +++ b/internal/tui3/homefate_test.go @@ -161,15 +161,21 @@ func TestProjectWithAPathAtHomePinsItWithoutTheBrowser(t *testing.T) { if !a.at(pageHome) { t.Fatal("/project <path> left home") } - if want := projectSetWord + tildePath(inner, a.tilde); a.home.msg != want { - t.Fatalf("home said %q, want %q", a.home.msg, want) - } - if a.home.msgPath != inner { - t.Fatalf("the sentence hangs its door on %q, want %q", a.home.msgPath, inner) + // AND IT SAYS NOTHING, because the row it would be drawn over is the row + // that answers. Home's sentence is drawn IN PLACE OF the keys row and + // stands until the next keystroke, so a success reported there hid the + // keys and said what `project: <path>` at their right end was already + // saying (projectcmd.go states the law). + if a.home.msg != "" { + t.Fatalf("a taken path wrote %q over home's keys row", a.home.msg) } - if text := ansi.Strip(a.homeFootLine(400, a.pal)); !strings.Contains(text, targetPathWord(a)) { + text := ansi.Strip(a.homeFootLine(400, a.pal)) + if !strings.Contains(text, targetPathWord(a)) { t.Fatalf("the keys row does not name the folder that was just pinned:\n%s", text) } + if !strings.Contains(text, homeOptionsWord) { + t.Fatalf("the keys are missing from the row that just pinned a folder:\n%s", text) + } } // AND A PATH THAT IS NOT A FOLDER IS REFUSED IN THE WORDS THAT WERE TYPED. diff --git a/internal/tui3/projectcmd.go b/internal/tui3/projectcmd.go index 0416af16f4..bf6c882cc3 100644 --- a/internal/tui3/projectcmd.go +++ b/internal/tui3/projectcmd.go @@ -30,12 +30,18 @@ import ( // The sentences /project says. Each is quoted in the manual exactly as it is // spelled here. const ( - // projectSetWord leads the line a taken path writes on home. The path is - // the whole of what a person cannot see anywhere else at that moment, so - // it is the ink and the label stays dim — which is how `folder ·`, - // `workspace ·` and `model ·` all say their answers (folderplace.go's - // [app.referPlace] states that law). - projectSetWord = "project · " + // A TAKEN PATH SAYS NOTHING HERE, and `project · <path>` stood in this spot + // until 2026-09-23. Home's sentence is the REFUSAL SLOT — it is drawn in + // place of the keys row and stands until the next keystroke takes it + // (homephone.go's [app.homeBar]) — so a success reported through it left + // the owner looking at a home whose keys row was gone, saying a thing the + // row it was covering already said: `project: <path>` at the right end, + // from the very next frame. Two sentences about one pin, one of them + // hiding the other. The browser road never said anything for the same + // reason (folderact.go's [app.targetFolderConfirm]), so the typed road was + // also the odd one out. A REFUSAL STILL SPEAKS: it is a fact about a door + // somebody just tried, and nothing else on the screen carries it. + // // projectNoFolderWord is a path that is not a directory on this machine: a // typo, a file, or somewhere that has been moved since. It names what was // typed rather than what it resolved to, because the resolved form is not @@ -80,7 +86,7 @@ func (a *app) runProjectCommand(rest string) tea.Cmd { // [app.targetFolderConfirm] says why that matters): the keys row under the // box says the new folder on the very next frame. a.target.where = path - a.home.say(projectSetWord+tildePath(path, a.tilde), path) + // AND THE ROW IS LEFT ALONE TO SAY IT (the constants above say why). a.touch() return nil } From c82c867fd70fd41ca0ee5474cea025de5b13cf42 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Wed, 23 Sep 2026 09:26:47 -0400 Subject: [PATCH 34/39] chat: the search place refuses again when memory is off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner's word, 2026-09-23: undo it. With no conversation store behind the window the place says `there is no index of this machine's conversations behind this window, so nothing can be searched from here.` and searches nothing, which is what it did until 2026-09-22. Gone with it: `byName`, `searchByName`, `searchByNameWord` and `searchNothingNamed`. `noIndex` is back, and it is again `a.searchStore == nil && !a.hosted()` — the hosted case has its own sentence naming WHOSE index is missing, which is the better one where it applies. `searchTick` stops reading the world and just puts the waiting flag down. WHY THE REFUSAL IS THE RIGHT ANSWER, written down where the flag is read: a place called `search` that quietly searches something narrower than it says reads exactly like a whole search that found nothing. The refusal is a fact a person can act on; half a search is one they cannot see. The `places` page says the refusal, says why, and points at home's box for finding a conversation by name — which is the door that has never needed the index. The `commands` page's `/search` paragraph follows. The test that held the by-name search now holds the refusal: nothing is drawn under the words, no store read goes out, and the page never claims nothing was said. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- internal/manual/chat/commands.md | 4 +- internal/manual/chat/places.md | 21 ++++---- internal/tui3/chattip_test.go | 44 +++++++--------- internal/tui3/helpreach_test.go | 9 ++-- internal/tui3/place_search.go | 27 +++++----- internal/tui3/searchplace.go | 87 +++++++++----------------------- 6 files changed, 71 insertions(+), 121 deletions(-) diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index 3c61954010..64f886bc70 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -759,8 +759,8 @@ and the note's leading `· `; strip those before feeding it to a parser. `/search` opens the **search place** — everything that has been said on this machine, found by the words you remember of it. It is the same place `alt+7` opens and the same place `tab` walks to. It takes no argument: the place *is* a box, and typing in it -searches. With the **memory** row off nothing said is indexed, and the place matches -conversations by their name and project instead, saying so (see the *places* page). +searches. With the **memory** row off nothing said is indexed, and the place says so and +searches nothing — find the conversation from home's box instead (see the *places* page). `/spend` opens the **spend place** — what this machine has cost, by the day, by the model and by what it was for. It is the same place `alt+3` opens. diff --git a/internal/manual/chat/places.md b/internal/manual/chat/places.md index cca79f6270..d148086112 100644 --- a/internal/manual/chat/places.md +++ b/internal/manual/chat/places.md @@ -639,17 +639,16 @@ With nothing typed the place is its heading `search` over one line saying what t **A search that finds nothing says what to do about it**: `nothing on this machine says "amber rail" · try fewer words, or a name`. -**With memory off, the place searches by name instead of refusing** (since 2026-09-22). -What was said is indexed only while the **memory** row is on — memory off opens no store -at all — so on such a machine the search place matches conversations the way home's box -does: every word you type has to appear in the conversation's name, its project's name or -its folder's name, and the matches come newest first with the project and the age but no -quoted turn. The empty place says so under its whisper: `what was said is not indexed while -memory is off · conversations match by their name and project`. A miss says -`no conversation on this machine is named "amber rail" · what was said is not indexed while -memory is off` — a different sentence from "nobody has said that", and the difference -matters. (Until 2026-09-22 this window said `there is no index of this machine's -conversations behind this window, so nothing can be searched from here.` and searched +**With memory off the place refuses, and says which silence it is.** What was said is +indexed only while the **memory** row is on — memory off opens no store at all — so this +window says `there is no index of this machine's conversations behind this window, so +nothing can be searched from here.` and searches nothing, whatever you type. That is +deliberate: a search that never happened must not report a result, because +`nothing on this machine says "amber rail"` would make you believe a conversation does not +exist. **To find a conversation on such a machine, use home's box**, which matches names +and projects and has never needed the index. (For one build on 2026-09-22 this place +matched by name itself; that was taken back on 2026-09-23, because a place called `search` +that searches something narrower than it says reads exactly like a whole search that found nothing.) Over `--host` a sentence at the top names the machine: the index is the one this machine's conversations were written into, and the conversation you are in was written on the other one. diff --git a/internal/tui3/chattip_test.go b/internal/tui3/chattip_test.go index acb6c2578d..0a9794a716 100644 --- a/internal/tui3/chattip_test.go +++ b/internal/tui3/chattip_test.go @@ -222,10 +222,13 @@ func TestEnterOnAttachInTheListOpensTheBrowserAtOnce(t *testing.T) { } } -// With no conversation store behind the window — memory off — the search place -// matches conversations by their name and project, the way home's box does, -// and says that is what it matched by. -func TestWithNoIndexTheSearchPlaceMatchesConversationsByName(t *testing.T) { +// WITH NO CONVERSATION STORE BEHIND THE WINDOW — memory off — THE PLACE +// REFUSES, and says which silence this is. It matched conversations by their +// name and project for one build on 2026-09-22, the way home's box does, and +// the owner took that back on 2026-09-23: a place called `search` that +// searches something narrower than it says is worse than one that refuses, +// because half a search reads exactly like a whole one that found nothing. +func TestWithNoIndexTheSearchPlaceSaysSoAndSearchesNothing(t *testing.T) { a := placeApp(t) a.searchStore = nil a.searchArm = func(int) tea.Cmd { return nil } @@ -233,34 +236,23 @@ func TestWithNoIndexTheSearchPlaceMatchesConversationsByName(t *testing.T) { _, world := searchFixture() a.search.world = world a.rebuildSearch() - if text := placeFrameText(a); !strings.Contains(text, searchByNameWord) { - t.Fatalf("the empty place does not say it matches by name:\n%s", text) + if text := placeFrameText(a); !strings.Contains(text, "no index of this machine's conversations") { + t.Fatalf("the place does not say it has no index:\n%s", text) } + // And typing does not send a read, nor draw a result under the words. typeInto(t, a, "swarm") if cmd := a.searchTick(searchTickMsg{gen: a.search.ask.gen}); cmd != nil { - t.Fatal("a search by name went out as a store read") + t.Fatal("a place with no index sent a store read") } - // The row carries the name as home spells it ([homeName]). - text := strings.ToLower(placeFrameText(a)) - if !strings.Contains(text, "swarm splitting") || strings.Contains(text, "lead research") { - t.Fatalf("the search by name did not find the conversation called that:\n%s", text) + text := placeFrameText(a) + if !strings.Contains(text, "no index of this machine's conversations") { + t.Fatalf("the refusal went away once words were typed:\n%s", text) + } + if strings.Contains(strings.ToLower(text), "swarm splitting") { + t.Fatalf("a place with no index drew a conversation it matched by name:\n%s", text) } if strings.Contains(text, searchNothingSaid("swarm")) { - t.Fatalf("a search by name claimed nothing was said:\n%s", text) - } - // A project name matches too. - a.search.query.reset() - typeInto(t, a, "leadgen") - a.searchTick(searchTickMsg{gen: a.search.ask.gen}) - if text := strings.ToLower(placeFrameText(a)); !strings.Contains(text, "leadgen") || strings.Contains(text, "swarm splitting") { - t.Fatalf("the search by name did not match on the project:\n%s", text) - } - // And nothing named that says so, without claiming nothing was said. - a.search.query.reset() - typeInto(t, a, "zzz") - a.searchTick(searchTickMsg{gen: a.search.ask.gen}) - if text := placeFrameText(a); !strings.Contains(text, "no conversation on this machine is named") { - t.Fatalf("a miss by name did not say so:\n%s", text) + t.Fatalf("a search that never happened claimed nothing was said:\n%s", text) } } diff --git a/internal/tui3/helpreach_test.go b/internal/tui3/helpreach_test.go index ea88a92f75..ead15c307d 100644 --- a/internal/tui3/helpreach_test.go +++ b/internal/tui3/helpreach_test.go @@ -337,11 +337,12 @@ func TestASearchThatFindsNothingSaysWhatToDoAndAMissingIndexSaysSo(t *testing.T) if cmd := a.searchTick(searchTickMsg{gen: a.search.ask.gen}); cmd != nil { t.Fatal("a surface with no index sent a read anyway") } - // With no index the place matches by name instead (since 2026-09-22, - // chattip_test.go), and a miss says so without claiming nothing was said. + // With no index the place refuses and says which silence this is. It + // matched by name instead for one build on 2026-09-22 and the owner took + // that back the next day (chattip_test.go). page := plain(placeFrameText(a)) - if !strings.Contains(page, `no conversation on this machine is named "report"`) { - t.Fatalf("a window with no index behind it does not say what it matched by:\n%s", page) + if !strings.Contains(page, "no index of this machine's conversations") { + t.Fatalf("a window with no index behind it does not say so:\n%s", page) } if strings.Contains(page, `nothing on this machine says "report"`) { t.Fatalf("a search that never happened reported a result:\n%s", page) diff --git a/internal/tui3/place_search.go b/internal/tui3/place_search.go index c3baee5397..f7d4e98bcb 100644 --- a/internal/tui3/place_search.go +++ b/internal/tui3/place_search.go @@ -113,11 +113,10 @@ func (a *app) rebuildSearch() { p.reading = next.unfolding(p.unfolded) // AND WHETHER THERE IS AN INDEX AT ALL IS A FACT ABOUT THE SURFACE, not // about the words: it is read here, where the reading is made, so the page - // can say what it matched by — what was said, or only what the - // conversations are called ([searchByNameWord]). The hosted case is - // answered further up by [placeSearch.remote], which says WHOSE index is - // missing and is the better sentence where it applies. - p.reading.byName = a.searchStore == nil + // can tell "nothing was said" from "nothing looked" ([searchNoIndexWord]). + // The hosted case is answered further up by [placeSearch.remote], which says + // WHOSE index is missing and is the better sentence where it applies. + p.reading.noIndex = a.searchStore == nil && !a.hosted() p.cursor = a.nearestSearchStop(p.cursor) } @@ -173,15 +172,15 @@ func (a *app) searchTick(msg searchTickMsg) tea.Cmd { return nil } if a.searchStore == nil { - // WITH NO INDEX BEHIND IT, THE NAMES ARE SEARCHED. Memory off means no - // store and so no record of what was said (cmd/codeaf's v3Memory), and - // until 2026-09-22 this place answered that by refusing to search at - // all — while home's box, one `esc` away, found the same conversations - // by name. So the world this place already holds is read instead, the - // way home reads it: a conversation matches by what it is called and - // what project it is in ([searchByName]), and the page says that is - // what it matched by. The answer needs no round trip, so it lands now. - a.searchDone(searchDoneMsg{ask: a.search.ask, hits: searchByName(a.search.ask.query, a.search.world)}) + // A CAPABILITY THAT CANNOT WORK IS ABSENT, NOT BROKEN. With no index + // behind it the place keeps saying what it is for rather than drawing an + // empty result list under somebody's words. + // + // It matched conversations by NAME here for one build on 2026-09-22, + // the way home's box does, and the owner took that back the next day: + // a place called `search` that searches something narrower than it says + // is worse than one that refuses. + a.search.waiting = false return nil } return searchCmd(a.searchStore, a.search.ask) diff --git a/internal/tui3/searchplace.go b/internal/tui3/searchplace.go index e4a4f9e77f..67f34b9093 100644 --- a/internal/tui3/searchplace.go +++ b/internal/tui3/searchplace.go @@ -9,7 +9,6 @@ package tui3 import ( "fmt" - "path/filepath" "regexp" "sort" "strings" @@ -50,12 +49,15 @@ type searchReading struct { hits []searchHit facets []searchFacet now time.Time - // byName says there is no conversation store behind this window, so the - // hits are conversations matched by their NAME and project rather than by - // what was said in them ([searchByName]) — and the page says so, because - // "nobody has said that" and "no conversation is called that" are two - // different sentences ([searchByNameWord], [searchNothingNamed]). - byName bool + // noIndex says there is no conversation store behind this window, and so + // nothing to search at all ([searchNoIndexWord]). + // + // THE PLACE MATCHED BY NAME HERE FOR ONE BUILD on 2026-09-22 — the way + // home's box does — and the owner took that back on 2026-09-23. A place + // called `search` that quietly searches something narrower than what it + // says is worse than one that refuses: the refusal is a fact a person can + // act on, and half a search reads like a whole one that found nothing. + noIndex bool // unfolded is whether every result is drawn rather than the first // [searchShown] and a fold line ([searchReading.unfolding]). unfolded bool @@ -171,24 +173,21 @@ func (r searchReading) paint(width int, pal palette, lit func(line int) bool) [] if room <= 0 { return nil } + if r.noIndex { + // AND A PLACE WITH NO INDEX BEHIND IT SAYS SO. Without this line a machine + // whose store was never wired answered `nothing on this machine says "x"`, + // which is a search that never happened reporting a result — the one + // sentence on this page that could make somebody believe a conversation + // does not exist. + return searchHung(placeTeachProse(searchNoIndexWord, width, pal)) + } if r.query == "" { // NOTHING TYPED IS AN EMPTY PLACE, and it says what arrives here and the // one thing that puts it there — the heading and the whisper every empty - // place draws (placeprose.go's [placeWhisper]). AND A PLACE WITH NO - // INDEX BEHIND IT SAYS SO, under the whisper: without that line a - // machine with memory off would answer `nothing on this machine says - // "x"` about words it never indexed — the one sentence on this page that - // could make somebody believe a conversation does not exist. - lines := placeWhisperLines(pageSearch, width, pal) - if r.byName && len(lines) > 0 { - lines = append(lines, placeWhisperLead+pal.dim(fit(searchByNameWord, width-len(placeWhisperLead)))) - } - return lines + // place draws (placeprose.go's [placeWhisper]). + return placeWhisperLines(pageSearch, width, pal) } if len(r.hits) == 0 { - if r.byName { - return searchHung(placeTeachProse(searchNothingNamed(r.query), width, pal)) - } return searchHung(placeTeachProse(searchNothingSaid(r.query), width, pal)) } var out []string @@ -227,50 +226,10 @@ func searchNothingSaid(query string) string { return fmt.Sprintf("nothing on this machine says %q · try fewer words, or a name", query) } -// searchByNameWord is the line under the whisper on a surface with no -// conversation store wired — memory off, on this machine. It says what the -// place CAN match here, because a capability that is only half there has to -// say which half (the emptiness law's cousin). -const searchByNameWord = "what was said is not indexed while memory is off · conversations match by their name and project" - -// searchNothingNamed is [searchNothingSaid] said honestly on that surface: -// no conversation is CALLED that, which says nothing about what was said. -func searchNothingNamed(query string) string { - return fmt.Sprintf("no conversation on this machine is named %q · what was said is not indexed while memory is off", query) -} - -// searchByName is the search this place runs with no store behind it: every -// word typed has to appear in the conversation's name, its project's name or -// its folder's name — the same three things home's box matches on — and the -// matches come newest first, as the store's would. A hit carries no quoted -// turn, so its row is the name, the project and the age. -func searchByName(query string, world session.World) []store.ConversationHit { - words := strings.Fields(strings.ToLower(query)) - if len(words) == 0 { - return nil - } - var hits []store.ConversationHit - for _, row := range world.Sessions() { - name := homeName(row) - hay := strings.ToLower(name + " " + row.Project + " " + filepath.Base(strings.TrimSpace(row.ProjectDir))) - all := true - for _, word := range words { - if !strings.Contains(hay, word) { - all = false - break - } - } - if !all { - continue - } - hits = append(hits, store.ConversationHit{MessageHit: store.MessageHit{SessionID: row.ID, Time: row.At}, Title: name}) - } - sort.SliceStable(hits, func(i, j int) bool { return hits[i].Time.After(hits[j].Time) }) - if len(hits) > searchFetch { - hits = hits[:searchFetch] - } - return hits -} +// searchNoIndexWord is the page over a surface with no conversation store +// wired: A CAPABILITY THAT CANNOT WORK IS ABSENT, NOT BROKEN, and this is the +// sentence that says which of the two silences this one is. +const searchNoIndexWord = "there is no index of this machine's conversations behind this window, so nothing can be searched from here." func (r searchReading) legend(width int, pal palette) string { parts := make([]string, 0, len(r.facets)) From df0889e6e3aa744d10579f873b1cc5bff2ffca04 Mon Sep 17 00:00:00 2001 From: Abir Abbas <abirabbas1998@gmail.com> Date: Thu, 24 Sep 2026 16:35:48 -0400 Subject: [PATCH 35/39] docs: the Home heading rename is #1415's entry, not this one's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1415 landed the same `scheduled` → `standing` rename with its own change entry, so this entry stops saying it a second time and the release notes name it once. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- docs/changes/unreleased/1388-restore-home-controls.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/docs/changes/unreleased/1388-restore-home-controls.md b/docs/changes/unreleased/1388-restore-home-controls.md index 0f02d7d6cf..965095e6ae 100644 --- a/docs/changes/unreleased/1388-restore-home-controls.md +++ b/docs/changes/unreleased/1388-restore-home-controls.md @@ -6,12 +6,9 @@ surface: [chat, docs] invalidates: - "Escape navigated outward until Home and two spaces only typed text. Two spaces in an empty box open Home again; Escape closes places into the conversation, interrupts its running answer, and double-Escape opens inline rewind." - "Home showed only search results above the seam and /ask selected a Home exchange. The ask-here and start-a-new-conversation rows are back below the results; Enter defaults to a conversation, Up then Enter asks here, and /ask is removed." - - "Home called its standing-orders panel scheduled. The heading is standing again, matching the place it opens." --- Restore the two interactions changed in #1071 while keeping subsequent command-menu, effort-shortcut, question-row, compact-layout and waiting-message fixes. The manual and terminal test recipes follow the restored keys. Deterministic tmux coverage drives the built binary at three widths and checks both submission routes with a local endpoint. - -Rename Home’s `scheduled` heading back to `standing` to match the tab it opens. From 80b7a5c4b204673be8541a4749c1bc8f5b0afd7a Mon Sep 17 00:00:00 2001 From: Abir Abbas <abirabbas1998@gmail.com> Date: Thu, 24 Sep 2026 16:38:45 -0400 Subject: [PATCH 36/39] tests: the tmux rig sees the setup form at forty-four columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Below sixty columns the first-run form draws no `setting up` title and its legend stops at `enter goes on · tab moves`, so the rig's launch wait never saw an interactive surface and TestHomeRestoredNavigationNoModel/44 failed after forty-five seconds on the setup screen. Both the launch wait and skipSetup now also read the legend's `tab moves`, which only the setup form draws. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- internal/e2e/tmux_test.go | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/internal/e2e/tmux_test.go b/internal/e2e/tmux_test.go index 83570970ca..fed9d9ea76 100644 --- a/internal/e2e/tmux_test.go +++ b/internal/e2e/tmux_test.go @@ -265,21 +265,30 @@ func (r *rig) skipSetup(t *testing.T) { // setupIsUp reports whether the first-run flow is on the frame right now. func (r *rig) setupIsUp() bool { screen := r.capture() - return strings.Contains(screen, setupSkipKeysWord) || strings.Contains(screen, setupTitleWord) + return strings.Contains(screen, setupSkipKeysWord) || strings.Contains(screen, setupTitleWord) || + strings.Contains(screen, setupMovesWord) } // setupPatience is how long [rig.skipSetup] waits for the flow to draw before // deciding this machine is not going to show one. const setupPatience = 8 * time.Second -// The two sentences that say the first-run flow is up. They are the SUITE'S OWN -// copies of internal/tui3's [setupSkipKeysWord] and the setup title, and they are -// spelled here rather than reached through [say] because tuiwords_test.go's own -// gate reads this file and every other one for the names it hands out — a door -// used by [start] itself has to stand before any scenario asks for a word. +// The sentences that say the first-run flow is up. They are the SUITE'S OWN +// copies of internal/tui3's [setupSkipKeysWord], the setup title and the form's +// legend, and they are spelled here rather than reached through [say] because +// tuiwords_test.go's own gate reads this file and every other one for the names +// it hands out — a door used by [start] itself has to stand before any scenario +// asks for a word. +// +// THE LEGEND'S SECOND CLAUSE IS HERE FOR NARROW FRAMES. Below sixty columns the +// form draws no title and its legend keeps only `enter goes on · tab moves` +// (internal/tui3's onboarding.go, [app.setupControlsKeys]), so a rig started at +// forty-four columns saw neither of the other two words, decided there was no +// setup, and left its scenario typing into the daily-limit field. const ( setupSkipKeysWord = "esc skips setup" setupTitleWord = "setting up" + setupMovesWord = "tab moves" ) // keylessEnv is every variable a fresh-install run must not inherit: the two the @@ -396,8 +405,12 @@ func startWithEnv(t *testing.T, env []string, name, home, ws string, cols, rows // forty-five seconds while looking at a perfectly live one. `needs you` is // drawn on every desktop home, whatever it holds: an empty panel keeps its // heading. + // + // AND THE SETUP FORM IS ONE AT EVERY WIDTH. Below sixty columns it draws + // neither its title nor `esc skips setup`, only the first two clauses of its + // legend, which is what [setupMovesWord] reads. if hit, _ := r.waitForAny(45*time.Second, say(t, "placeRestWord"), - say(t, "starterTaskWord"), say(t, "setupTitleWord"), say(t, "setupSkipWord"), + say(t, "starterTaskWord"), say(t, "setupTitleWord"), say(t, "setupSkipWord"), setupMovesWord, say(t, "landingKeysWord"), say(t, "welcomeStarterKeysWord"), say(t, "answersAllowOnce"), say(t, "homeAnswerHint"), say(t, "homeNeedsHeading")); hit == "" { t.Fatal("the terminal never reached an interactive surface") From ae442b8d8d23262dc6d529640a69024b7aeb3fd2 Mon Sep 17 00:00:00 2001 From: Abir Abbas <abirabbas1998@gmail.com> Date: Thu, 24 Sep 2026 16:42:08 -0400 Subject: [PATCH 37/39] chat: home's folder sheet stays about the next conversation when the store lands The first `/project` or bare `/attach` of a launch opens its sheet before the background read of the pick counts answers, and that answer rebuilds the sheet through folderPick.start, which cleared forTarget. A second after opening, `the next conversation's folder` became `add context`, and the folder chosen on it was added to the conversation behind home. The rebuild keeps the flag and leaves such a sheet holding nothing, and a test lands the store mid-sheet. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- internal/tui3/folderplace.go | 12 ++++++++- internal/tui3/homefate_test.go | 45 ++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/internal/tui3/folderplace.go b/internal/tui3/folderplace.go index 91ddb0ff42..f3d6cc134c 100644 --- a/internal/tui3/folderplace.go +++ b/internal/tui3/folderplace.go @@ -1189,12 +1189,22 @@ func (a *app) tookFolderStore(msg folderStoreMsg) tea.Cmd { hidden, gen, cols := a.folder.hidden, a.folder.gen, a.folder.cols marks, pane := a.folder.marks, a.folder.pane paneTop, paneLeft := a.folder.paneTop, a.folder.paneLeft + forTarget := a.folder.forTarget a.folder.start(a.folderCandidates(), a.tilde) a.folder.filter, a.folder.facts = filter, facts a.folder.kids, a.folder.asking, a.folder.hidden = kids, asking, hidden a.folder.marks, a.folder.pane = marks, pane a.folder.paneTop, a.folder.paneLeft = paneTop, paneLeft - a.markFolderHeld() + // AND SO DOES WHO THE SHEET IS ABOUT. Home's sheet chooses for the + // conversation that does not exist yet ([app.openTargetContextPick]), and a + // rebuild that dropped the flag turned the first `/project` or bare `/attach` + // of a launch into `add context` a second after it opened, so the folder + // chosen on it went to the conversation BEHIND home. Its sheet holds nothing + // either, for the reason that function gives. + a.folder.forTarget = forTarget + if !forTarget { + a.markFolderHeld() + } // The COLUMNS are kept whole and not re-seated: which level they are on and // which row of it the cursor is on are facts about where a person has walked // to, and a store arriving is not news about either. diff --git a/internal/tui3/homefate_test.go b/internal/tui3/homefate_test.go index 8ae8d6fc12..31661ce692 100644 --- a/internal/tui3/homefate_test.go +++ b/internal/tui3/homefate_test.go @@ -15,6 +15,7 @@ import ( "testing" "time" + tea "charm.land/bubbletea/v2" "github.com/charmbracelet/x/ansi" ) @@ -141,6 +142,50 @@ func TestProjectAtHomeBrowsesForTheTargetAndPinsIt(t *testing.T) { } } +// THE STORE LANDING DOES NOT CHANGE WHO THE SHEET IS ABOUT. The first browser of +// a launch opens before the background read of the pick counts answers, and +// that answer rebuilds the sheet; a rebuild that forgot [folderPick.forTarget] +// turned home's `the next conversation's folder` into `add context`, and the +// folder chosen a second later was referred to the conversation BEHIND home — +// caught in a real terminal on a fresh profile, where every test here had +// already read the store. +func TestTheStoreLandingKeepsHomesSheetAboutTheNextConversation(t *testing.T) { + a, _, root := mixedLab(t) + runCmd(a.openHome()) + + settleFolder(t, a, a.homeSlash("/project")) + if !a.folder.open || !a.folder.forTarget { + t.Fatalf("bare /project did not open the browser for the target: open=%v target=%v", + a.folder.open, a.folder.forTarget) + } + + // The store lands, exactly as the background read delivers it. + settleFolder(t, a, func() tea.Msg { + return folderStoreMsg{store: folderStore{Roots: []string{filepath.Join(root, "here")}}} + }) + if !a.folder.open || !a.folder.forTarget { + t.Fatalf("the store landing turned home's sheet into the conversation's: open=%v target=%v", + a.folder.open, a.folder.forTarget) + } + if len(a.folder.held) > 0 { + t.Fatalf("the store landing marked the conversation behind home's folders as held: %v", a.folder.held) + } + + onFolderRow(t, a, "inner") + settleFolder(t, a, a.folderConfirm()) + + inner := filepath.Join(root, "here", "inner") + if a.target.where != inner { + t.Fatalf("the pick pinned %q, want %q", a.target.where, inner) + } + if !a.at(pageHome) { + t.Fatal("the pick did not land back on home") + } + if len(a.attachedPlaces()) > 0 { + t.Fatalf("the pick was referred to the conversation behind home: %v", a.attachedPlaces()) + } +} + // /project WITH A PATH TAKES THE PATH AND OPENS NOTHING. A person who typed the // folder has already answered the question the browser exists to ask, and the // pin is a string on this window rather than a round trip — so the keys row From 034f316e24abf3d15f98546524a7b05f189d6a88 Mon Sep 17 00:00:00 2001 From: Abir Abbas <abirabbas1998@gmail.com> Date: Thu, 24 Sep 2026 16:42:09 -0400 Subject: [PATCH 38/39] manual: two spaces open home again, and the pinned project is on the keys row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2e2b5ed67 taught esc as the door home in five passages, one day before #1388 gave two spaces back; the merge kept those passages, so keys, home, places and running-on-another-machine told a person the opposite of what the binary does. They say `space` `space` again. Also: a /project or /attach pin on home says nothing and shows at the right of the keys row (not `next conversation opens in`, not on the rule), a task page's question is reached through home with two spaces, and only home's tip row takes turns — a conversation's ranks. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- internal/manual/chat/attaching-files.md | 6 +++--- internal/manual/chat/choosing-a-folder.md | 6 +++--- internal/manual/chat/commands.md | 3 ++- internal/manual/chat/hints-and-tips.md | 5 +++-- internal/manual/chat/home.md | 8 ++++---- internal/manual/chat/keys.md | 4 ++-- internal/manual/chat/places.md | 12 +++++------- internal/manual/chat/questions.md | 2 +- internal/manual/chat/running-on-another-machine.md | 2 +- 9 files changed, 24 insertions(+), 24 deletions(-) diff --git a/internal/manual/chat/attaching-files.md b/internal/manual/chat/attaching-files.md index db4b7237f0..50ab0a9f74 100644 --- a/internal/manual/chat/attaching-files.md +++ b/internal/manual/chat/attaching-files.md @@ -436,9 +436,9 @@ used to answer `<name> is a folder · attach a file`; locally it now goes to the folder door and says `folder · ~/code/thing`. Over `--host`, it registers nothing and says `choosing a folder is not available over --host yet — the folders here are this machine's, not the ones the conversation is on.` -On home it pins the next conversation's folder instead and says -`next conversation opens in ~/code/thing`, because there is no conversation there to attach -one to. Dropping a folder on the window still refuses with the old +On home it pins the next conversation's folder instead, because there is no conversation +there to attach one to: it says nothing, and `project: ~/code/thing` at the right of the keys +row under the box shows the pin. Dropping a folder on the window still refuses with the old `<name> is a folder · attach a file` sentence — see "Choosing a folder". ## Attaching a file from home — /attach on the home screen, before there is a conversation diff --git a/internal/manual/chat/choosing-a-folder.md b/internal/manual/chat/choosing-a-folder.md index d7499f0cac..64188321a4 100644 --- a/internal/manual/chat/choosing-a-folder.md +++ b/internal/manual/chat/choosing-a-folder.md @@ -142,9 +142,9 @@ same fact — the conversation it is choosing for does not exist yet: - The action row reads **`open the next conversation in · ~/src/parser`** instead of `add this folder`, and it never offers `remove this folder` — the conversation this is choosing for has no folders yet. -- `enter` **pins** the folder: home comes back with the rule already changed and says - `next conversation opens in ~/src/parser`. Nothing is registered with any session until you - actually start one. +- `enter` **pins** the folder: home comes back with `project: ~/src/parser` at the right + of the keys row under the box, and says nothing else. Nothing is registered with any + session until you actually start one. **`esc` comes back to home too**, having changed nothing — the browser only replaced home because a sheet takes the whole frame. Files chosen on that sheet still go where files go: diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index 64f886bc70..8a19bebee5 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -1986,7 +1986,8 @@ coming it steers that turn, exactly as a plain `enter` does. **On home it opens a conversation first.** Home is not a conversation, so `/manual` there is one of the commands that *opens a conversation here first* (see the home page): a -conversation opens at the folder and the model on the rule above the box, home closes, +conversation opens at the folder named at the right of the keys row and the model on the +rule above the box, home closes, and the question is sent there. Until 2026-09-22 `/manual` on home printed its answer into the conversation *behind* home, where nothing could be seen of it — typing it looked like nothing happening. diff --git a/internal/manual/chat/hints-and-tips.md b/internal/manual/chat/hints-and-tips.md index 8a165ec533..3b2620fa04 100644 --- a/internal/manual/chat/hints-and-tips.md +++ b/internal/manual/chat/hints-and-tips.md @@ -89,9 +89,10 @@ launch starts every tip from nothing. ## The tip on home changed by itself — the order the tips come round in, and the tip that jumps the queue -Both rows take turns through the one list, in the order below, round and round: every tip +Home's row takes turns through the one list, in the order below, round and round: every tip that is true for you gets its turn before any repeats, and a tip that stops being true -stands down at once for the next. Nothing outranks anything — with one exception. **A tip +stands down at once for the next. (A conversation's keys row does not take turns: it ranks, +and the first tip in the list that is true for you there is the one it says.) Nothing outranks anything — with one exception. **A tip that has just become true jumps the queue**: when a conversation crosses half its context window, `/compact summarizes the conversation now` is said next rather than forty minutes later when the ring comes round. It jumps once and then takes its turn like the rest. diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index e99da60a8f..15c49b1c86 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -497,7 +497,7 @@ and says `here`. ## Where the cursor starts on home — on my previous chat — and where the first down arrow goes -**Opening home with `esc`, `/home` or `alt+1` puts the cursor on the conversation +**Opening home with `space` `space`, `/home` or `alt+1` puts the cursor on the conversation this window was in before the one in front** — the most recent other one on this window's own tab stack — so going back is `enter`. A window that has held only one conversation has no "before", and the cursor is on its own row in the conversation list, which says `here`. @@ -672,7 +672,7 @@ Four ways, and each of them is you saying which conversation you mean: | `codeaf chat --session <path>` | that conversation, no home | | `codeaf resume` | the session picker, no home | | `codeaf chat --once "text"` | replies printed with no surface; one reply normally, or every landing-woken reply when `--yolo` has a budget | -| `codeaf --host <machine>` | the far machine's session, no greeting — `esc` opens that machine's home | +| `codeaf --host <machine>` | the far machine's session, no greeting — `space` `space` opens that machine's home | And on a machine with only one conversation — a first run — home does not greet you. There is no setting for this and no flag to turn it off: whether home greets you follows @@ -1062,7 +1062,7 @@ All of the following holds over the ordinary engine socket, `--host`, `--at` and ## How do I switch to my other chat — and is it still running -**`tab` with an empty message box**, or `esc` and then `enter` — home opens with +**`tab` with an empty message box**, or `space` `space` and then `enter` — home opens with the cursor already on the chat you were in before this one. Either goes straight to it; nothing is reopened and nothing is replayed from cold that does not have to be. @@ -1286,7 +1286,7 @@ one behind your back. This is every fate, in the words the drop-up draws them in | **`opens the page`** | `/settings` `/set` `/config` · `/home` · `/search` · `/spend` · `/standing` · `/memory` `/memories` · `/history` · `/task` (bare) | A place replaces a place, exactly as before. | | **`this list is /resume`** | `/resume` `/sessions` | Says `this list is /resume · enter opens a row` — home *is* that list. | | **`onto home's tray`** | `/attach <path>` | The file — or picture — rides on home's own tray into the conversation you open next. Home says `attached · notes.md · rides with the next conversation`. A bare `/attach` opens the browser aimed at the next conversation's folder, and a file chosen there lands on the tray. | -| **`opens a conversation here first`** | `/files` · `/folder` `/place` `/dir` · `/manual` · `/crew` (bare) · `/permissions` `/perms` · `/connect` · `/harness` · `/subharness` · `/copy` · `/select` · `/rewind` `/undo` `/back` · `/compact` · `/export` `/save` · `/standing <words>` · `/task <brief>` | Opens a conversation at the target — the folder and model on the rule above the box — then runs there. Home closes, exactly as `enter` closes it. `/manual` is on this road since 2026-09-22: it is a question put to the model, so it needs a conversation to be asked in. `/folder` joined it the same day — it gives THIS conversation a folder, and home has no this; the pin it used to be here is `/project`. | +| **`opens a conversation here first`** | `/files` · `/folder` `/place` `/dir` · `/manual` · `/crew` (bare) · `/permissions` `/perms` · `/connect` · `/harness` · `/subharness` · `/copy` · `/select` · `/rewind` `/undo` `/back` · `/compact` · `/export` `/save` · `/standing <words>` · `/task <brief>` | Opens a conversation at the target — the folder at the right of the keys row and the model on the rule above the box — then runs there. Home closes, exactly as `enter` closes it. `/manual` is on this road since 2026-09-22: it is a question put to the model, so it needs a conversation to be asked in. `/folder` joined it the same day — it gives THIS conversation a folder, and home has no this; the pin it used to be here is `/project`. | | **`answers here`** | `/help` · `/status` · `/cost` · `/cache` · `/budget` · `/crew <preset>` · `/debug` · `/stop` · `/remember` · `/forget` · a word nobody defined | Answers with a note, and the first line of that note is put on home's own line under the box. `there is no command called /pricing · / lists them` is now something you can read. | | **`runs on the conversation behind home`** | `/land` · `/land <folder>` · `/workspace <path>` | Acts on the conversation this window is holding behind the screen — not on the one `enter` would open — and its answer is echoed onto home's line. | | **`a fresh conversation behind home`** | `/new` `/clear` `/clean` `/reset` | Replaces the conversation behind the screen and says `started a fresh conversation behind home`. It is not the same act as `enter`, which opens a conversation at the target. | diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index 61b19b9736..4d41bc9dae 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -595,7 +595,7 @@ key arrives as ordinary `enter` and the message steers instead. | `alt+e` | Walk this conversation's thinking rung one step: auto → low → medium → high → xhigh → max, and back to auto. Works with a sentence half typed. On home and every other place it walks the rung of the **next** conversation instead — the effort word after the model’s colon on home’s seam | | `alt+a` | Walk what this conversation runs without asking one stop: asks → guardian → YOLO → asks. Never lands on `refuses`. Works with a sentence half typed; over `--host` it says the far machine's rules decide. On home and every other place it walks the gate of the **next** conversation — the `◇` cell on the rule above that box — and that pin is spent by the conversation that uses it | | `ctrl+.` | Open the sessions place (`/history`) — every task this machine has run, across every project and every session; type to filter it. It opens on a machine that has run nothing too, and the page says what tasks are | -| `space` `space` | Two spaces, nothing more. This used to open home over an empty box; that door closed on 2026-09-17, and `esc` is the way home — see *Escape, esc, back, and getting home without stopping work* | +| `space` `space` | On an **empty** box: open home (`/home`) — every project and conversation on the machine the session runs on, and an empty home on a fresh one. Does nothing when the box has words in it | | `ctrl+l` | Jump back to the live edge of the conversation | | `ctrl+t` | Start a **new chat** — the same start page the `+` at the end of the tab strip opens. Nothing is created until you send the first message, `esc` comes back, and the conversation you were in keeps its draft, its attachments and its work. On a home row it starts the fresh chat in that row's own folder, the same door as `enter` on a `projects` row | | `ctrl+w` | **Close this tab** — the same thing the `✕` on it does. Selects the last-used remaining tab, or Home if none remain. Drafts are kept, and the conversation keeps running; a tab with work in it asks `keep running` / `stop work` / `cancel` first | @@ -1510,7 +1510,7 @@ an email address, a Go doc link — never opens the list. **What it walks:** the conversation's workspace, or **your own machine's** working directory over `--host`. **On home** the same list opens over home's box (since -2026-09-22) and walks the folder the next conversation opens in — the one on the rule — +2026-09-22) and walks the folder the next conversation opens in — the `project:` at the right of the keys row — so moving the target with `alt+p` or `/project` walks again; it offers files and folders there and never tasks, because a task pointer is minted when a conversation sends and home has none yet. Skipped: `.git`, `vendor`, `node_modules`, every diff --git a/internal/manual/chat/places.md b/internal/manual/chat/places.md index d148086112..b3d02acc85 100644 --- a/internal/manual/chat/places.md +++ b/internal/manual/chat/places.md @@ -241,12 +241,12 @@ this process was started with it. **The `here ~/codeaf` chip is gone**, and so are the rules that the other places used to draw over their boxes. The arrow and `new conversation in` lead are gone from home too; -the model starts the seam, and the project sits at the far right. A place with something to say about its page — `nothing matches` +the model starts the seam, and the project sits at the far right of the keys row under the box. A place with something to say about its page — `nothing matches` on tasks when a filter emptied it, a receipt on memory, the "this session is on another machine" line over `--host` — says it on its rule, where the box's rule would have been. **Over `--host`, and on a session with no dial**, the rung and the gate are simply not on -home's rule — the folder and the model still are. The far machine's rows decide what a +home's rule — the model still is, and the folder is still at the right of the keys row. The far machine's rows decide what a conversation there runs without asking. ## Why did pressing alt+enter not send my task straight away @@ -810,16 +810,14 @@ afternoon. place segment and the legend under the box already carry. On a local session it is not there at all: a machine name is worth a word only when there is more than one machine in play. -## Why is home empty over ssh when I connect to another machine — space space, once Escape, over --host +## Why is home empty over ssh when I connect to another machine — space space over --host **It is not empty any more, and this is the answer if you have seen it be.** -`esc` over `--host` opens the home of the machine your session runs on: its +`space` `space` over `--host` opens the home of the machine your session runs on: its projects, its conversations, and what each of those ran. `enter` on a row opens that conversation beside the one you are in — the engine gives it a connection of its own and -the chat you came from keeps running, the same door `codeaf resume` uses locally. Two -spaces over an empty box used to be this door as well; since 2026-09-17 `space` `space` -types two spaces and nothing more, here and locally, and `esc` is the key. +the chat you came from keeps running, the same door `codeaf resume` uses locally. It used to draw **one dim line** where the rows would be — `home shows this machine's projects, and this session is on another` — because the projects diff --git a/internal/manual/chat/questions.md b/internal/manual/chat/questions.md index 38ef9032db..abeb890f7e 100644 --- a/internal/manual/chat/questions.md +++ b/internal/manual/chat/questions.md @@ -1372,7 +1372,7 @@ under what it has read: It is dim and it takes no key. **A page you are only reading cannot answer** — amber and a key would be this page promising something it does not have. Go to -the chat itself (`esc`, then the row on home) and the question is there with its +the chat itself (`space` `space` for home, then its row there) and the question is there with its answers on it. Without that line, a page like this drew a running clock over work that had not diff --git a/internal/manual/chat/running-on-another-machine.md b/internal/manual/chat/running-on-another-machine.md index 6cb4abf4f8..f0e74dc846 100644 --- a/internal/manual/chat/running-on-another-machine.md +++ b/internal/manual/chat/running-on-another-machine.md @@ -268,7 +268,7 @@ on a remote path — expect to see the full path. Yes, and they show **the far machine's**. -`esc` opens the home of the machine your session runs on: its projects, its +`space` `space` opens the home of the machine your session runs on: its projects, its conversations, what each of them ran, and what keeps an eye on it. `enter` on a row opens that conversation beside the one you are in — the engine gives it a connection of its own and the chat you came from keeps running, the same door `codeaf resume` uses locally. The right end of the tab bar reads `on <machine>` so you can From 17809f7446a10cdc070c0fcbd5715d380c4e247a Mon Sep 17 00:00:00 2001 From: Abir Abbas <abirabbas1998@gmail.com> Date: Thu, 24 Sep 2026 16:53:37 -0400 Subject: [PATCH 39/39] manual: a pasted folder's leftover text goes to the project on the keys row Since this branch the next conversation's project is named at the right of the keys row, not on the seam. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- internal/manual/chat/home.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index 35bebdd570..ae38a25196 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -1236,7 +1236,7 @@ The complete paste must name one existing local directory. The action row reads conversation there without sending the path as a message. Any other key — including space, an arrow, Backspace, a shortcut or Shift+Enter — cancels the offer and keeps normal editing behavior. A second paste also cancels it. The remaining text is an ordinary -message for the project selected on the seam; returning to the same path does not rearm +message for the project selected at the right of the keys row; returning to the same path does not rearm it. Clear the box and paste the folder path again to get a fresh offer. Pasting into existing text, including whitespace or a newline, never activates the offer.