diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 4fb094604..91f927d48 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` | @@ -330,7 +330,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/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 000000000..92a8ee440 --- /dev/null +++ b/docs/changes/unreleased/1389-one-tip-list-two-boxes.md @@ -0,0 +1,37 @@ +--- +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 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 ` 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." + - "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 +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. diff --git a/internal/config/settings.go b/internal/config/settings.go index 379e00817..8f53d85d8 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -2679,14 +2679,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 54ca91ea7..f749576b3 100644 --- a/internal/manual/chat/asking-from-home.md +++ b/internal/manual/chat/asking-from-home.md @@ -8,9 +8,9 @@ Yes. Type it on the home screen, press `↑` once — which lands on the row spe ``` ? 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 + ─ glm-5.3-flash:auto · ◇ asks ────────────────────────────────────────────── › 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 + enter starts a new conversation and sends this · ↑ ask here · esc clear project: ~/codeaf ``` What you get is **a row in home's conversation list and a pane holding the exchange**. The row diff --git a/internal/manual/chat/attaching-files.md b/internal/manual/chat/attaching-files.md index 9debcbf94..50ab0a9f7 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 @@ -434,9 +436,9 @@ used to answer ` 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 ` 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 @@ -444,13 +446,16 @@ 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 -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. +**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.) `/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 @@ -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 d449e4324..64188321a 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,24 +104,47 @@ 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 rule above -it says where that will be: `glm-5.3-flash:auto · ◇ asks ─── 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, 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. + +**`/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 `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: @@ -128,7 +152,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 @@ -697,7 +721,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 @@ -709,6 +733,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 +764,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 0b26a3392..c0eebf1ce 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,16 +156,17 @@ 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 | | `/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 | -| `/attach` | `/upload` | — | opens the add context sheet for files, including over `--host` | +| `/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 | | `/land` | — | `now` | …puts it in: a branch merged for a repository, files copied back for a plain folder | @@ -208,9 +209,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 @@ -410,6 +410,36 @@ ctrl+b does. In copy mode ↑↓ move, `v` marks, `a` takes the block, `y` yanks 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 + +`/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 · the keys row 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 @@ -437,30 +467,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. - -A full attachment tray does not stop the command: `/image` adds a second picture rather -than sending the first. +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. -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. +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. -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 ``` @@ -730,7 +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. +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. @@ -1759,8 +1789,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. @@ -1941,28 +1970,36 @@ 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` 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 — how do I read the manual, is there a help page, show me the page about a command, ask codeaf about itself -Three forms, and which one you get is decided by what you type after the word: +`/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. -| 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 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. + +**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 @@ -2017,13 +2054,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 @@ -2032,11 +2069,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 e6c6e4ad0..3b2620fa0 100644 --- a/internal/manual/chat/hints-and-tips.md +++ b/internal/manual/chat/hints-and-tips.md @@ -1,72 +1,223 @@ # 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 — `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 `esc esc takes back the last -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. +## What was that tip at the bottom of the screen — the one-line hint, the sentence under the message box + +The dim row under your message box — the last row of the frame — mostly 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, `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 or /rewind takes back an earlier message` +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 +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-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 +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. + +## 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: `💡 /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 +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 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 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. 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 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, +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. 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. + +## 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 + +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. (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. ## 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. +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. + +**Starting work** -- `/ 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`. -- `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 +- `/cost says what this conversation has spent` — once the conversation has spent about + ten cents. Retired when you run `/cost`. +- `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 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`. -- `/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. - -## 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 — `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. +- `/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 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 conversation tab` — retired the first time the chord is + pressed, on a terminal that can send it. + +**Files and context** + +- `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. +- `/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 + an export lands. + +**Models, thinking and cost** + +- `/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. +- `/budget sets the spending cap for the day` — retired when `/budget` answers. + +**Steering a running answer** + +- `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. + +**Moving around** + +- `ctrl+t starts a fresh chat in this project` — retired when the new-chat page opens. + +**Memory, accounts and the rest** + +- `/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 + 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. + **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. + +- `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, and the door followed: `/ask` is not a command any more, and asking from home is + the `ask here` row over home's box. It was the only tip 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. +- `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. + +`/ 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. 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 — 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 — `esc 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. ## What "news" lines are — what's new after an update @@ -78,4 +229,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 f32d47b96..ae38a2519 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -1194,18 +1194,22 @@ quoted back — sitting directly above the box you are typing into. ? ask here: "pricing" + start a new conversation: "pricing" - ─ glm-5.3-flash:auto · ◇ asks ─── project: ~/codeaf + ─ glm-5.3-flash:auto · ◇ asks ─────────────────────────────────────── › pricing - enter starts a new conversation and sends this · ↑ ask here · ↑↑ pick a match · alt+p project · alt+e effort · alt+a approvals · esc clear + enter starts a new conversation and sends this · ↑ ask here · esc clear project: ~/codeaf ``` **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. -The effort word follows a colon with no badge. A long project path is cut on the right. +fresh conversation and sends what you typed, however many matches are on screen. One `↑` +reaches `ask here`, which sits directly on top of it, and a second reaches the matches. + +**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` @@ -1232,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. @@ -1281,12 +1285,12 @@ 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, 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 ` · `/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`. | -| **`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. | +| **`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` · `/folder` `/place` `/dir` · `/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 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 ` · `/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. | @@ -1300,18 +1304,20 @@ 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. -**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 +`/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 -path on the seam. +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 and back out to home, and the chips you put on it here are the chips the next conversation @@ -1322,10 +1328,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: @@ -1360,10 +1366,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. Clicking a project name in the panel selects it directly. All three controls share one -selection, shown only as `project: ` on the seam. If `/folder` selected a destination outside the panel, +selection, shown only 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. @@ -1385,12 +1392,45 @@ 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 `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 -`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 without a word — the keys row's own `project: ` is +the answer; 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. + +## 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 — `/project sets the project folder +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 +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 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 + +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 at the right of the keys row), 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 — and it is the innermost layer of the three esc +walks through on home, so the first `esc` takes the list and a later one leaves home. 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`. ## How do I get back to the dashboard or the home screen from any page — press space twice diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index 471a11feb..7213acc59 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` | 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. 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 | @@ -1361,9 +1361,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. @@ -1408,7 +1410,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. @@ -1416,20 +1418,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 ` | @@ -1467,7 +1469,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? @@ -1507,7 +1509,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 `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 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. @@ -1544,10 +1550,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 @@ -1560,7 +1566,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 @@ -1799,7 +1805,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 @@ -2271,10 +2277,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` @@ -2880,13 +2887,24 @@ 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 — 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 | @@ -3318,7 +3336,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/places.md b/internal/manual/chat/places.md index 2c46a9caf..391046ac9 100644 --- a/internal/manual/chat/places.md +++ b/internal/manual/chat/places.md @@ -200,18 +200,24 @@ 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 + 💡 /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 · 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. 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. **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 | |---|---|---| @@ -237,12 +243,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 @@ -606,7 +612,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 +642,19 @@ 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 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. 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/questions.md b/internal/manual/chat/questions.md index 38ef9032d..abeb890f7 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 97fbba54a..f0e74dc84 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/screen.md b/internal/manual/chat/screen.md index 5e7159764..e4c4d89ed 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 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. 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; @@ -684,9 +687,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 @@ -762,7 +769,11 @@ 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 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, `esc interrupt` while a turn is running, `enter steers it in · ctrl+shift+enter stops and sends · esc interrupt` while a turn is @@ -1352,9 +1363,10 @@ Under 60 columns, eight things change shape: 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 - 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/what-i-can-do.md b/internal/manual/chat/what-i-can-do.md index ecbacea2f..d29226f73 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/working-indicator.md b/internal/manual/chat/working-indicator.md index 4a8e0ca70..e5a696753 100644 --- a/internal/manual/chat/working-indicator.md +++ b/internal/manual/chat/working-indicator.md @@ -18,9 +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. It also steps aside in copy mode. 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. +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 50eb72dc3..d7c7b13e3 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -537,11 +537,31 @@ 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"}, {"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"}, + // 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 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"}, // 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. @@ -2377,6 +2397,20 @@ 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"}, + {"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"}, + {"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"}, // 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 @@ -2615,6 +2649,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 2d3943488..9f467430f 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -2286,6 +2286,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. @@ -3368,7 +3376,9 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyPressMsg: a.sawAPerson() - // 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]). @@ -3537,6 +3547,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() @@ -4045,6 +4059,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 @@ -4543,6 +4563,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) @@ -5764,7 +5786,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). + // decided on, and it is the turn [noticeGap] is counted in (notice.go). a.noticeEvent(eventTurnEnded) a.follow() a.touch() @@ -6866,6 +6888,7 @@ func (a *app) slash(line string) tea.Cmd { return a.runUpdateCommand(rest) case "autonomy": + a.noticeEvent(eventAutonomyAsked) if rest != "" { return a.changeAutonomy(rest) } @@ -7043,23 +7066,26 @@ 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 // (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 @@ -7067,6 +7093,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) @@ -7158,6 +7185,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 @@ -7277,13 +7305,13 @@ func (a *app) slash(line string) tea.Cmd { return a.runCacheCommand(rest) case "manual": - // 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 + a.noticeEvent(eventManualAsked) + // 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 @@ -8502,6 +8530,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 1a2ef9fa0..aa11770e7 100644 --- a/internal/tui3/attach.go +++ b/internal/tui3/attach.go @@ -272,34 +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) { - 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 @@ -314,6 +295,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 @@ -381,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, "~"), "/")) @@ -394,6 +376,30 @@ 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) + // 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] + } + 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 80fd085a1..bceec8db1 100644 --- a/internal/tui3/attach_test.go +++ b/internal/tui3/attach_test.go @@ -419,10 +419,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) } @@ -430,27 +432,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") } @@ -460,7 +459,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 d7046da00..94d59dc3d 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/boxseam_test.go b/internal/tui3/boxseam_test.go index 5fd322384..1629c613a 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/budget.go b/internal/tui3/budget.go index bff41e9eb..613bdc518 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/bundle_test.go b/internal/tui3/bundle_test.go index 60f1a2a2b..ed31984e1 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/chatstart.go b/internal/tui3/chatstart.go index 2eff13911..e55f44902 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/chattip_test.go b/internal/tui3/chattip_test.go new file mode 100644 index 000000000..0a9794a71 --- /dev/null +++ b/internal/tui3/chattip_test.go @@ -0,0 +1,292 @@ +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, ITS KEYS ROW'S PROJECT, AND TWO DOORS ─────────── +// +// 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)) { + 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 +} + +// 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 + 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 != 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, deliverTip) { + t.Fatalf("the keys row does not carry the tip: %q", got) + } + y, rows := tipRowOf(a, deliverTip) + if y < 0 { + t.Fatalf("the tip is not on the frame:\n%s", strings.Join(rows, "\n")) + } + 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")) + } + // 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 tip drew over a box with a letter in it: %q", got) + } + drive(t, a, key("backspace")) + if got := a.noticeHint(); got != deliverTip { + t.Fatalf("emptying the box did not give the row back: %q", got) + } + + // 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.state = stateIdle + a.showPage(pageSpend) + if got := a.noticeHint(); got != "" { + t.Fatalf("the tip drew under a place: %q", got) + } + a.leavePlace() + if got := a.noticeHint(); got != deliverTip { + t.Fatalf("leaving the place did not give the row back: %q", got) + } +} + +// Silencing hints silences the conversation's row along with home's. +func TestDisableHintsSilencesTheConversationRow(t *testing.T) { + a, _ := chatTipLab(t) + makeDeliverable(t, a) + 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) + } + 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) + } +} + +// 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 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 } + a.showPage(pageSearch) + _, world := searchFixture() + a.search.world = world + a.rebuildSearch() + 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 place with no index sent a store read") + } + 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 that never happened claimed nothing was said:\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 f5919e623..cf38cec0e 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 @@ -108,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 @@ -415,21 +421,28 @@ 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 // 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/connectpanel.go b/internal/tui3/connectpanel.go index 025c80c9f..4bd1d2050 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/contextmodal_test.go b/internal/tui3/contextmodal_test.go index a5d07df3b..0e9fbacb6 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/copymode.go b/internal/tui3/copymode.go index fb2fc206b..41bb80d7e 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 { @@ -159,6 +164,9 @@ func (a *app) enterCopy() { } 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 { diff --git a/internal/tui3/crew.go b/internal/tui3/crew.go index f9c63cfca..0d7239cff 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/export_test.go b/internal/tui3/export_test.go index d7231b643..9b1ff6893 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 732359138..b8041935b 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/foldercontext_test.go b/internal/tui3/foldercontext_test.go index cc2916abe..f18e5f450 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 ee485c63c..87a2f01c4 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 1fcdace12..f3d6cc134 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,7 +242,16 @@ 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 { +// +// 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(eventProjectSet) + } if a.hosted() { a.home.say(folderRemoteWord, "") return nil @@ -310,6 +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 { + // 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 @@ -1168,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/followup.go b/internal/tui3/followup.go index 2fdb0c493..1e3b9f23b 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/foot.go b/internal/tui3/foot.go index 682fefb98..0a8eb44b1 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 096d2f4bc..5901af868 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/head_test.go b/internal/tui3/head_test.go index bb3559b41..edc1486a0 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/helpreach_test.go b/internal/tui3/helpreach_test.go index d14d3a763..ead15c307 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" ) @@ -338,6 +337,9 @@ 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 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 index of this machine's conversations") { t.Fatalf("a window with no index behind it does not say so:\n%s", page) @@ -347,41 +349,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 2e6dd3ed0..2ed40c4db 100644 --- a/internal/tui3/home.go +++ b/internal/tui3/home.go @@ -549,6 +549,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 @@ -654,6 +658,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. @@ -1218,6 +1227,13 @@ 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]) — 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 @@ -1500,6 +1516,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() { // 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 @@ -1682,9 +1705,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 { @@ -2048,6 +2078,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). @@ -2344,7 +2376,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: @@ -2586,6 +2618,16 @@ func (a *app) homeKey(msg tea.KeyPressMsg) tea.Cmd { if a.cancelTakeover() { return nil } + // THE @ LIST IS INNER TO THE BOX, and so it goes first. It is drawn + // under home's box and belongs to the half-typed token in it + // (homeat.go), so esc over an open list means "not that one" and must + // leave the sentence exactly as it was — clearing the box here would + // take the word the list was opened for along with the list. + if h.comp.open { + h.dismissCompletion() + h.build() + return nil + } if !h.box.empty() { h.box.reset() h.build() @@ -2889,6 +2931,11 @@ func (a *app) homeKey(msg tea.KeyPressMsg) tea.Cmd { return nil case "ctrl+b": + // 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 @@ -3138,6 +3185,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 homeAskHere: // The same sentence, asked rather than opened (homeexchange.go). return a.askHere(strings.TrimSpace(h.box.String())) @@ -4031,6 +4081,13 @@ func (a *app) homePress(x, y int) tea.Cmd { if a.homePhone() { return a.homePhonePress(x, y) } + // 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 + } // 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). @@ -4556,6 +4613,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: @@ -4689,6 +4750,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). @@ -5453,6 +5517,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/home_test.go b/internal/tui3/home_test.go index 0648de054..ab434cca7 100644 --- a/internal/tui3/home_test.go +++ b/internal/tui3/home_test.go @@ -343,25 +343,41 @@ func TestHomeEscGoesBackToTheConversation(t *testing.T) { } } -// esc peels one layer: a box with something in it is cleared before the screen -// is left. -func TestHomeEscClearsTheBoxBeforeItLeaves(t *testing.T) { +// esc peels ONE LAYER AT A TIME, and there are three of them on home: the `@` +// list under the box, then the box itself, then the screen. The draft here is +// `@x`, which opens the list, so all three are in play — and the list goes +// first, because clearing the box would take the token the list was opened for +// with it (homeat.go). +func TestHomeEscPeelsTheListThenTheBoxThenTheScreen(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")) + if !a.home.comp.open { + t.Fatal("@x did not open the completion list") + } + a.homeKey(key("esc")) + if a.home.comp.open { + t.Fatal("the first esc did not close the list") + } + if !a.at(pageHome) { + t.Fatal("the first esc left home instead of closing the list") + } + if got := a.home.box.String(); got != "@x" { + t.Fatalf("closing the list changed the draft to %q", got) + } a.homeKey(key("esc")) if !a.at(pageHome) { - t.Fatal("the first esc left home instead of clearing the box") + t.Fatal("the second 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") + t.Fatal("the third esc did not close home") } } @@ -972,6 +988,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) @@ -2216,6 +2237,19 @@ func (l *homeLab) door(standing string) *app { return a } +// goHome walks through the door the way a person does, which is TWO SPACES IN +// AN EMPTY BOX and not esc — esc went back to being the interrupt, the layer +// peel and the arming half of rewind on 2026-09-23 (#1388), and a test that +// still pressed it was testing a key that no longer opens anything. +func goHome(t *testing.T, a *app) { + t.Helper() + a.key(key(" ")) + a.key(key(" ")) + if !a.at(pageHome) { + t.Fatal("two spaces did not open home") + } +} + // TWO SPACES IN AN EMPTY BOX GO HOME. func TestDoubleSpaceInAnEmptyBoxGoesHome(t *testing.T) { lab := newHomeLab(t) @@ -3184,8 +3218,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/homeat.go b/internal/tui3/homeat.go new file mode 100644 index 000000000..1e0e9679b --- /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 000000000..5f0288781 --- /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/homedraft.go b/internal/tui3/homedraft.go index 2664c2f3e..34918eabf 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 } @@ -365,6 +365,7 @@ func (a *app) pinTargetProject(path string) 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 b3e80abc2..cbd178fe6 100644 --- a/internal/tui3/homeexchange.go +++ b/internal/tui3/homeexchange.go @@ -972,6 +972,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/homefate_test.go b/internal/tui3/homefate_test.go index 9c9a1c9cf..31661ce69 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" ) @@ -74,10 +75,15 @@ 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}, - {"image", "shot.png", fateTray}, + {"attach", "shot.png", fateTray}, {"pricing", "", ""}, } { if got := homeFate(want.word, want.rest); got != want.fate { @@ -86,19 +92,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. @@ -124,9 +130,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. @@ -135,19 +142,152 @@ 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) { +// 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("/folder "+filepath.Join(root, "here")+"/")) + 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("/folder at home did not open the target's browser: open=%v target=%v", + t.Fatalf("the store landing turned home's sheet into the conversation's: open=%v target=%v", a.folder.open, a.folder.forTarget) } - 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 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 +// says the new folder on the very next frame. +func TestProjectWithAPathAtHomePinsItWithoutTheBrowser(t *testing.T) { + a, _, root := mixedLab(t) + runCmd(a.openHome()) + + 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") + } + // 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: ` 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) + } + 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. +// 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 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("") + goHome(t, a) + 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) } } @@ -159,7 +299,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 { @@ -198,9 +338,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) @@ -210,22 +350,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 ac3cb2475..3026a546f 100644 --- a/internal/tui3/homephone.go +++ b/internal/tui3/homephone.go @@ -688,6 +688,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 b118d0348..0f3ae82d5 100644 --- a/internal/tui3/homeslash.go +++ b/internal/tui3/homeslash.go @@ -184,13 +184,15 @@ func homeFate(word, rest string) string { switch canonicalCommand(strings.ToLower(strings.TrimPrefix(word, "/"))) { 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 case "resume": return fateResume - case "attach", "image": + case "attach": return fateTray case "quit": return fateQuit @@ -199,7 +201,16 @@ 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", "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 @@ -238,7 +249,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 } @@ -253,10 +264,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). @@ -285,13 +292,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, "") @@ -359,15 +365,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/homeslash_test.go b/internal/tui3/homeslash_test.go index 7327c189f..d5c8daba6 100644 --- a/internal/tui3/homeslash_test.go +++ b/internal/tui3/homeslash_test.go @@ -290,12 +290,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) } } @@ -502,7 +507,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") } } @@ -538,14 +543,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.go b/internal/tui3/hometip.go new file mode 100644 index 000000000..8db2eb8e2 --- /dev/null +++ b/internal/tui3/hometip.go @@ -0,0 +1,119 @@ +package tui3 + +import ( + "strings" + + "github.com/charmbracelet/x/ansi" + + "github.com/Agent-Field/codeaf/internal/tui2/tokens" +) + +// ── THE TIP ROW, AND HOME'S KEYS ROW ───────────────────────────────────────── +// +// 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 ─────────────────────────────────────────────────────────────── +// › 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 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. 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 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, 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 a keys row — the root and an ellipsis, or nothing. +const homeFootPathFloor = 4 + +// 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) tipLine(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) + text, span, ok := projectAtRight(project, used, width) + if !ok { + return line + } + a.targetFolderSpan = span + pad := width - 1 - used - 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 +} + +// 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 new file mode 100644 index 000000000..27272448f --- /dev/null +++ b/internal/tui3/hometip_test.go @@ -0,0 +1,477 @@ +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 [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 +// box with nothing else up. +func TestHomeRowSaysATipOverAnEmptyBox(t *testing.T) { + lab := newHomeLab(t) + a := lab.door("") + goHome(t, a) + 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, "enter ") { + 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 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) + } + 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 +// [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) + 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(hintEvery - 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 * 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) + } + 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 row 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) + } +} + +// 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} { + 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++ { + b.advance[slot] = true + 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, 0) + 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) + } + 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, 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 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, 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 }, 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, /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 TestTheTableIsTwentyTwoHintsAndEveryOneDrawsOnBothBoxes(t *testing.T) { + hints := 0 + for _, n := range notices { + if n.slot != slotHint { + continue + } + hints++ + if !n.draws(slotHome) || !n.draws(slotHint) { + t.Errorf("hint %q does not draw on both boxes", n.id) + } + if n.draws(slotNote) { + t.Errorf("hint %q draws in the transcript", n.id) + } + } + 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) { + 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") + } +} + +// ── the cross ─────────────────────────────────────────────────────────────── + +// 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 } + runCmd(a.showPage(pageHome)) + + was := a.notices.current[slotHome] + if was == "" { + t.Fatal("home opened with no tip") + } + now = now.Add(noticeReadTime * 3) + a.noticeDismiss(slotHome) + + 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) + } + if a.notices.retired(was) { + 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 + 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) + } +} + +// 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) + makeDeliverable(t, a) + if a.noticeHint() == "" { + t.Fatal("the conversation says no tip to begin with") + } + frame(a) + if a.tipCloseSpan.pressable() { + t.Fatalf("a conversation drew a cross at columns %+v", a.tipCloseSpan) + } + // 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, deliverTip) { + t.Fatalf("the conversation's tip is not on the keys row: %q", got) + } +} + +// 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. 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 != 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/hometiplayout_test.go b/internal/tui3/hometiplayout_test.go new file mode 100644 index 000000000..1f3d1ec1a --- /dev/null +++ b/internal/tui3/hometiplayout_test.go @@ -0,0 +1,208 @@ +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 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) + 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 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") + } + a.homePress(a.tipCloseSpan.from-4, a.tipRow) + 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 answered with another tip: %q", got) + } + if strings.Contains(homeText(a), tip) { + t.Fatal("the tip is still drawn after its cross was pressed") + } + // 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") + } + if got := a.notices.ledger.shown(was); got != 0 { + t.Fatalf("the cross spent %d showings of the tip it put away", got) + } + // 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) + } +} + +// 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.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.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") + } + 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/host_test.go b/internal/tui3/host_test.go index b6211610f..927851c18 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 @@ -311,7 +316,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/hover.go b/internal/tui3/hover.go index 5a9f86c05..9ffb5a223 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/imagepaste_test.go b/internal/tui3/imagepaste_test.go index 11d17f3c0..3d2924e13 100644 --- a/internal/tui3/imagepaste_test.go +++ b/internal/tui3/imagepaste_test.go @@ -475,16 +475,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/manualcmd.go b/internal/tui3/manualcmd.go index e854ed492..032454d90 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 2ecf231fc..50bbd535e 100644 --- a/internal/tui3/manualcmd_test.go +++ b/internal/tui3/manualcmd_test.go @@ -5,71 +5,102 @@ 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("") + goHome(t, a) + 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/memory.go b/internal/tui3/memory.go index 96e8d9460..f72d3d1a7 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 27f904561..e538a5613 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" ) @@ -16,18 +17,32 @@ 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 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 +// 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, 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 // the build on a duplicate id, an empty line, a retire event nobody defined, or @@ -44,26 +59,32 @@ 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 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 // 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 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 ) @@ -112,6 +133,61 @@ 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" + // 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" + // 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 — + // /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" + // 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" + // 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 @@ -121,6 +197,11 @@ var noticeEvents = []string{ eventMenuOpened, eventRewound, eventCopyEntered, eventModelSwitched, eventCompacted, eventFilesOpened, eventResumeOpened, eventCostShown, eventStandingOpened, eventDeliverableMade, + eventAsked, eventTaskTyped, eventManualAsked, eventTabReopened, eventAtOpened, eventAttached, + eventFolderPicked, eventProjectSet, eventModelListOpened, eventCrewShown, eventBudgetShown, + eventSpendOpened, eventSteered, eventQueued, eventChatStarted, + eventPlaceJumped, eventRemembered, eventSearchOpened, eventSubharnessOpened, + eventConnectOpened, eventAutonomyAsked, } // notice is one thing the surface may tell a person, and the whole of the rule @@ -131,9 +212,6 @@ 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. - 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 @@ -148,28 +226,46 @@ 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 + // [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 +// 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 + +// 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 -// 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. +// 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 + // The arming thresholds, each named once so the manual page and the table // cannot drift apart about when a hint appears. const ( @@ -184,12 +280,49 @@ const ( costHintUSD = 0.10 ) -// 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. +// 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 } + // 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 +// 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. +// +// 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 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; 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 rows this surface shipped with ───────────────────────────────── { - id: "compact-at-half", slot: slotHint, priority: 90, + id: "compact-at-half", slot: slotHint, armed: func(a *app) bool { pct, ok := a.ctxPercent() return ok && pct >= contextHintPct @@ -198,50 +331,199 @@ var notices = []notice{ retire: eventCompacted, }, { - id: "cost-after-spend", slot: slotHint, 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, }, + // `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: "task-page-after-first-task", slot: slotHint, 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, + // 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, }, { - id: "files-after-first-deliverable", slot: slotHint, 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", + text: "/files finds files codeaf wrote 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, 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, - armed: func(a *app) bool { return len(a.welcome.recent) >= 3 }, - text: "/standing keeps something always true", + id: "standing-after-several-sessions", slot: slotHint, + 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 message into a rule work must follow", retire: eventStandingOpened, }, + // ── starting work ─────────────────────────────────────────────────────── + // + // `/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, + text: "/task starts a single-shot task on the side", + 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 makes your message a rule instead of a request", + retire: eventStandingOpened, + }, + { + id: "manual-answers", slot: slotHint, + armed: ready, + text: "/manual answers any question about codeaf", + retire: eventManualAsked, + }, + { + id: "reopen-tab", slot: slotHint, + armed: ready, + text: "ctrl+shift+t reopens the last conversation tab", + retire: eventTabReopened, + }, + // ── files and context ─────────────────────────────────────────────────── + { + id: "at-completion", slot: slotHint, + armed: ready, + text: "type @ to find paths in the current project", + retire: eventAtOpened, + }, + { + id: "attach-a-file", slot: slotHint, + armed: ready, + text: "/attach sends a file or folder with your message", + retire: eventAttached, + }, + // `/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 project folder for a new conversation", + retire: eventProjectSet, + }, + { + id: "export-the-conversation", slot: slotHint, + armed: func(a *app) bool { return a.turn >= 2 }, + text: "/export writes the current conversation to a file", + retire: eventDeliverableMade, + }, + // ── models, thinking and cost ─────────────────────────────────────────── + { + id: "model-list", slot: slotHint, + armed: ready, + text: "/model lets you see and choose models and providers", + retire: eventModelListOpened, + }, + { + 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, + armed: ready, + text: "/budget sets the spending cap for the day", + retire: eventBudgetShown, + }, + // ── steering a running answer ─────────────────────────────────────────── + { + // 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: "using enter steers conversations · use ctrl+q to queue messages", + retire: eventQueued, + }, + // ── moving around ─────────────────────────────────────────────────────── + { + id: "new-chat", slot: slotHint, + armed: ready, + text: "ctrl+t starts a fresh chat in this project", + retire: eventChatStarted, + }, + // ── memory, accounts and the rest ─────────────────────────────────────── + { + id: "remember-one-thing", slot: slotHint, + 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, + }, + { + id: "connect-accounts", slot: slotHint, + armed: ready, + text: "/connect links Notion, Slack and other services", + retire: eventConnectOpened, + }, + { + // 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: 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", + retire: eventAutonomyAsked, + }, } // noticeBanned is the machinery vocabulary no person-facing line may carry. @@ -276,6 +558,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 == slotHome: + 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 { @@ -304,6 +588,15 @@ func (n notice) limit() int { return noticeShownDefault } +// 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 { + if n.slot == slotHint { + return slot == slotHint || slot == slotHome + } + return n.slot == slot +} + // line is what the notice says right now. func (n notice) line(a *app) string { if n.say != nil { @@ -325,7 +618,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 @@ -336,11 +630,42 @@ 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. + // 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 + // ── 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 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 home's beat. + at [noticeSlots]time.Time + // 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 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 + + // ── 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 hint slot last changed hands on, or -1 when - // it never has; [noticeGap] is measured from it. + // 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 } @@ -370,6 +695,15 @@ func newNoticeBoard(path, build string, enabled bool) noticeBoard { 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 + // 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. @@ -409,79 +743,187 @@ 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: -// -// - 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. +// 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 { - held := b.current[slot] - best, found := noticeCandidate{}, false + 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 !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 eligible(c) { + best = c.id + break } } - if !found { + if best == "" { return "" } - if slot == slotHint && best.id != held && b.lastHintTurn >= 0 && turn-b.lastHintTurn < noticeGap { + 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 && c.armed && !b.done[c.id] && !b.retired(c.id) { + if c.id == held && eligible(c) { return held } } return "" } - return best.id + return best } -// 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. -func (b *noticeBoard) take(slot noticeSlot, id string, limit int, turn int) (changed, wrote bool) { +// 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 +// 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) 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] + b.advance[slot] = false + for _, c := range cands { + if c.fresh && c.id != held && eligible(c) { + return c.id + } + } + at := -1 + for i, c := range cands { + if c.id == held { + at = i + } + } + 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. 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. +// +// 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, turn int) (changed, wrote bool) { if b.current[slot] == id { return false, false } + wrote = b.settle(slot, now, limitOf) b.current[slot] = id - if id == "" { - return true, false - } 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 } - if b.shown[id] { - return true, false + 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.shown[id] = true - count := b.ledger.show(id) - if count >= 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. + 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 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 { b.ledger.retire(id) } - return true, true + return true } // ── THE SURFACE'S SIDE ────────────────────────────────────────────────────── @@ -524,60 +966,150 @@ 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 { - 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 { continue } - cands = append(cands, noticeCandidate{id: n.id, priority: n.priority, armed: n.armed(a)}) - limits[n.id] = n.limit() + 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 } id := b.pick(slot, cands, a.turn) - changed, wrote := b.take(slot, id, limits[id], a.turn) + now := a.now() + 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 + // that row leaves the frame ([noticeBoard.hidden]), and a tip arriving + // behind it is a tip nobody is being shown. + b.at[slot] = now + } + // 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 a.noticeLive(slot) { + b.visible(slot, now) + } if changed && id != "" { a.noticeShow(slot, id) } return wrote } -// 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. -func (a *app) noticeShow(slot noticeSlot, id string) { - if slot != slotNote { +// 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 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 + } + switch slot { + case slotHome: + return a.at(pageHome) + case slotHint: + return false + } + 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 { if n.id == id { - a.note(n.line(a)) - return + return n.limit() } } + return noticeShownDefault } -// 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. +// noticeRotate moves a row on to the next tip. It is asked on every visit to +// 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 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. -func (a *app) noticeHint() string { +// 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 - id := b.current[slotHint] - if id == "" || !b.enabled || !a.noticeQuiet() { + if b.seen == nil { + *b = bareNoticeBoard() + } + b.advance[slot] = true + 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 +// [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() { + b := &a.notices + if b.current[slotHome] == "" || !a.noticeHomeQuiet() { + return + } + if a.now().Sub(b.at[slotHome]) >= hintEvery { + 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 || 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 n.line(a) @@ -586,12 +1118,82 @@ func (a *app) noticeHint() string { 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.comp.open && !a.home.searching() && + a.paneExchange() == nil && !a.targetPickShowing() && !a.composer.open && !a.hopShowing() +} + +// 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. 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{} + b.hidden[slot] = true + b.advance[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 + } + if line := a.noticeLine(id); line != "" { + a.note(line) + } +} + +// 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 +// the list here is [app.noticeQuiet]. +func (a *app) noticeHint() string { + b := &a.notices + id := b.current[slotHint] + if id == "" || !b.enabled || !a.noticeQuiet() { + 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() } // 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 c090d70ac..7972e4e54 100644 --- a/internal/tui3/notice_ledger.go +++ b/internal/tui3/notice_ledger.go @@ -55,13 +55,43 @@ 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 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 0314df44b..6d31d6925 100644 --- a/internal/tui3/notice_test.go +++ b/internal/tui3/notice_test.go @@ -130,7 +130,12 @@ 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" + // 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 @@ -142,6 +147,8 @@ func TestAHintAgesOutAcrossOrdinaryLaunches(t *testing.T) { if got := a.notices.current[hintSlotForTest]; got != hint { t.Fatalf("launch %d holds %q in the hint slot, want %q", session, got, hint) } + // 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) } @@ -259,105 +266,224 @@ 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) { +// 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 } } + +// 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{ - {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(slotHome, cands, 0); got != "first" { + t.Fatalf("the slot picked %q, want the first eligible", got) + } + 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[slotHome] = true + got := b.pick(slotHome, cands, 0) + if got != want { + t.Fatalf("the ring went to %q, want %q", got, want) + } + b.take(slotHome, got, true, now, fixedLimit(6), 0) } - 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}}, 0); 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 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() - 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) + now := time.Date(2026, 9, 22, 9, 0, 0, 0, time.UTC) + cands := []noticeCandidate{ + {id: "compact", armed: false}, + {id: "first", armed: true}, + {id: "second", armed: true}, + } + if got := b.pick(slotHome, cands, 0); got != "first" { + t.Fatalf("the slot picked %q", got) + } + b.take(slotHome, "first", true, now, fixedLimit(6), 0) + cands[0] = noticeCandidate{id: "compact", armed: true, fresh: true} + if got := b.pick(slotHome, cands, 0); got != "compact" { + t.Fatalf("a fresh tip did not jump the ring: %q", got) + } + 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(slotHome, cands, 0); got != "compact" { + t.Fatalf("a tip that had jumped was moved by an event: %q", got) + } + 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(slotHome, "first", true, now, fixedLimit(6), 0) + cands[1].armed = false + if got := b.pick(slotHome, cands, 0); 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) { +// 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) { b := freshBoard() - b.take(slotHint, "tip", 2, 1) - b.take(slotHint, "", 2, 2) - b.take(slotHint, "tip", 2, 3) - if got := b.ledger.shown("tip"); got != 1 { - t.Fatalf("one session counted %d showings", got) + 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(slotHome, "tip", true, now, limit, 0) + now = now.Add(5 * time.Second) + 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(slotHome, "tip", true, now, limit, 0) + b.take(slotHome, "tip", true, now, limit, 0) + now = now.Add(noticeReadTime) + 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(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(slotHome, "", false, now, limit, 0) + b.take(slotHome, "tip", false, now, limit, 0) + now = now.Add(time.Hour) + 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(slotHome, now) + now = now.Add(noticeReadTime) + 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) } 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, + // and the slot is cleared as the notice retires. next := newNoticeBoard("", "", true) next.ledger = b.ledger - next.take(slotHint, "tip", 2, 1) + next.take(slotHome, "tip", true, now, limit, 0) + 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[slotHint] != "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 // 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", 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, 9); got != "" { + if got := b.pick(slotHint, cands, 0); got != "" { t.Fatalf("a retired notice came back: %q", got) } } +// 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 @@ -384,83 +510,98 @@ 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 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]. +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) } - if got := a.footHint(a.width); got != taskPageTip { - t.Fatalf("the hint slot reads %q, want the tip", 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 != deliverTip { + t.Fatalf("the tip row reads %q, want the tip", got) } - if !strings.Contains(plain(frame(a)), 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)), deliverTip) { 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 != "esc 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) + } + // AND THE ROW SAYS THE ONE KEY THAT MATTERS WHILE A TURN IS RUNNING. esc is + // the interrupt again (#1388), and no tip outranks it. + if got := plain(a.footHint(a.width)); !strings.Contains(got, "esc interrupt") { + t.Fatalf("a running turn's keys row does not offer the interrupt: %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 != 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") } } @@ -507,6 +648,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") }, + 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 { + 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") }, + // /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") }, + 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") }, + eventAutonomyAsked: func(t *testing.T, a *app) { a.slash("/autonomy") }, } for _, name := range noticeEvents { if name == eventBoot { @@ -555,25 +736,24 @@ 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) + makeDeliverable(t, a) + if got := a.noticeHint(); got != deliverTip { + t.Fatalf("the tip row reads %q before the toggle", got) } a.openSettings() for i, tab := range settingTabs { - if tab == tabDisplay { + if tab == tabWorkspace { a.sheet.tab = i } } @@ -590,12 +770,12 @@ 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 == 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) } @@ -608,7 +788,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...) @@ -680,8 +860,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 { @@ -722,3 +902,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/pages.go b/internal/tui3/pages.go index 48d882ad8..ec99bee2f 100644 --- a/internal/tui3/pages.go +++ b/internal/tui3/pages.go @@ -1400,7 +1400,27 @@ 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. + // + // IT IS RIGHT-ALIGNED, led by a bulb and closed by a cross (hometip.go's + // [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.tipLine(tip, width, pal); line != "" { + a.tipCloseSpan, tipTop = span, len(lines) + add(line, nil) + } else { + add("", 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 @@ -1524,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) } @@ -1565,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) } @@ -2246,6 +2287,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 c87eaa0ef..daf98913f 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/place_home.go b/internal/tui3/place_home.go index 5e6037753..35639a80f 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/place_search.go b/internal/tui3/place_search.go index e4e1d62fb..f7d4e98bc 100644 --- a/internal/tui3/place_search.go +++ b/internal/tui3/place_search.go @@ -175,6 +175,11 @@ func (a *app) searchTick(msg searchTickMsg) tea.Cmd { // 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 } diff --git a/internal/tui3/placekeys.go b/internal/tui3/placekeys.go index a7840e1e5..7aac05bea 100644 --- a/internal/tui3/placekeys.go +++ b/internal/tui3/placekeys.go @@ -320,6 +320,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/placemouse.go b/internal/tui3/placemouse.go index 063aa0e64..2fd748e45 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/projectcmd.go b/internal/tui3/projectcmd.go new file mode 100644 index 000000000..bf6c882cc --- /dev/null +++ b/internal/tui3/projectcmd.go @@ -0,0 +1,100 @@ +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 ( + // A TAKEN PATH SAYS NOTHING HERE, and `project · ` 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: ` 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 + // 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 + // AND THE ROW IS LEFT ALONE TO SAY IT (the constants above say why). + 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 dee524066..e0a55d7d1 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,27 @@ 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 +} + +// 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 { text = pal.seamModel(text) @@ -44,11 +79,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/projectseam_test.go b/internal/tui3/projectseam_test.go index e6e24880f..2e0f84b07 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 5c2d16084..c64b134dd 100644 --- a/internal/tui3/render.go +++ b/internal/tui3/render.go @@ -3353,10 +3353,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() { @@ -3725,11 +3727,16 @@ 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 + // 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 } @@ -3882,7 +3889,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 diff --git a/internal/tui3/room.go b/internal/tui3/room.go index cef3c074d..560fb4e2f 100644 --- a/internal/tui3/room.go +++ b/internal/tui3/room.go @@ -2087,7 +2087,8 @@ func (a *app) freezeRoom() { 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. + // 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) diff --git a/internal/tui3/searchplace.go b/internal/tui3/searchplace.go index 2c572ad76..67f34b909 100644 --- a/internal/tui3/searchplace.go +++ b/internal/tui3/searchplace.go @@ -49,9 +49,14 @@ 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 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]). diff --git a/internal/tui3/settings.go b/internal/tui3/settings.go index 91208c378..301868298 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, diff --git a/internal/tui3/statusdeck_test.go b/internal/tui3/statusdeck_test.go index d27e50db9..99f18bef0 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/steer.go b/internal/tui3/steer.go index c056faaa9..5d2c48417 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/tabreopen.go b/internal/tui3/tabreopen.go index 23673c19e..0f8480514 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 } diff --git a/internal/tui3/taskcommand.go b/internal/tui3/taskcommand.go index 3998b4c57..4e29b7e00 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` diff --git a/internal/tui3/view.go b/internal/tui3/view.go index f09562e02..c93551424 100644 --- a/internal/tui3/view.go +++ b/internal/tui3/view.go @@ -675,7 +675,9 @@ 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. + // 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 diff --git a/internal/tui3/worklogo_test.go b/internal/tui3/worklogo_test.go index 3717b3014..dd2637aa0 100644 --- a/internal/tui3/worklogo_test.go +++ b/internal/tui3/worklogo_test.go @@ -94,7 +94,7 @@ func TestWorkingLogoFallbacksAndTransientRows(t *testing.T) { for _, change := range []func(*app){ func(a *app) { a.linear = true }, func(a *app) { a.pal.ascii = true }, func(a *app) { a.pal = newPalette(tokens.NoColor, false) }, func(a *app) { a.width = 40 }, - func(a *app) { a.height = 16 }, func(a *app) { a.copy.on = true }, + func(a *app) { a.height = 16 }, func(a *app) { a.state = stateInterrupted }, func(a *app) { a.page = pageHome }, } { a := workLogoApp(t)