diff --git a/docs/changes/unreleased/1725-mention-list-recents-and-chat-scope.md b/docs/changes/unreleased/1725-mention-list-recents-and-chat-scope.md new file mode 100644 index 000000000..184d06f46 --- /dev/null +++ b/docs/changes/unreleased/1725-mention-list-recents-and-chat-scope.md @@ -0,0 +1,27 @@ +--- +kind: fixed +title: the @ list finds recent conversations on every box without taking over prose +pr: 1725 +surface: [chat, docs] +invalidates: + - "The `@` list's recent conversations were read once per process, so a conversation started in another window after the first `@` was never on it. They are read once per opening on every box when no read is pending: a new `@` token, typed or pasted, or the list returning on the next letter after `esc`. Openings during a read share one follow-up." + - "`@chat:` and `@team:` kept the first eight rows of their section and showed no sign of more. A prefixed list keeps up to thirty-two and scrolls; the bare `@` still keeps eight per section." + - "The manual did not say which conversations the `@` list holds. It does: every open tab except the one you are in and the window's own unnamed, unsent front, then the twenty most recent in this project; open tabs from other projects are included too; older or other-project saved conversations use `/resume` unless already open here." + - "On the new-chat page (`+`), the `@` list left off the conversation the window came from, as though you were typing inside it, so `@chat:kim` beside a lit `tell me about kim jung il` said `no conversation matches`. The start page leaves no eligible conversation off." + - "A prefixed `@chat:who is` search closed at its first space. `@team:`, `@chat:` and `@file:` now hold up to three spaces and match every word in any order on teams, conversations and files. A bare `@` still ends at its first space, so ordinary prose never reopens the list. A multi-word search with no match closes only after its catalog has been read." + - "Home's `@` list offered files alone, and `@chat:` typed there answered `no file matches`. Home's list has the same teams and conversations sections and the same `@team:`, `@chat:` and `@file:` prefixes as a conversation's box, and leaves no eligible conversation off." + - "Home's `@` list could clear its paths under already-ranked rows and crash, including on dev. It now walks the pinned target or this window's file root, never the row selected before opening. Locally the list, foot and sentence use the same folder; over `--host` the unpinned list walks this machine's folder like a conversation's list, while the foot and send keep the far workspace. Catalogs and rows change together; old-folder answers are ignored; closed-list arrows preserve the completed walk." + - "A pasted opening could skip the fresh recent read when an earlier catalog matched nothing. A new token now asks for fresh recents exactly once, even when its unmatched display closes; openings during a pending read share one follow-up, keeping at most one walk in flight per window; the earlier answer never settles their search as fresh; letters and caret moves within that opening never restart the read or repeat the opening notice. Dismissing it with `esc` ends the opening on every box; a space or punctuation keeps even a bare `@` closed without a read; the next letter brings it back with one fresh read and one opening notice. Prefixed multi-word chat searches wait for their token's answer." + - "Recent rows or a file walk could move the chosen row before Enter, and home's arrows left its completion cursor behind. Data arrivals select the best match unless the person chose a row since the query last changed; that choice survives while still offered. A changed query returns to the best match." + - "Emptying home's box skipped completion sync and kept its list open, so the next token could miss conversations started in another window. Every edit now syncs the token, including the empty box; the next `@` asks for fresh rows." + - "Recent-row canonicalization walked the disk on the update loop. Keys now travel with the off-loop read, preserving symlink deduplication and the hosted cleaned-path rule." + - "Punctuation after a chosen mention reopened an empty list, and home could draw chosen teams plain from a stale conversation catalog. Punctuation keeps the list closed; every box draws the current team colour, and plain draft rows allocate nothing for team painting." + - "An unsent shell could appear on the `@` list under its draft's tab name and supply an empty digest. Only the window's own unnamed front with no user entry or opening prompt is left off for having nothing sent. Every held, restored or side tab is offered without title/opening heuristics, including an untitled conversation whose first user message was a queued follow-up." +--- + +Santosh's report (2026-09-30): a tab reading `cloudfl…` was on the strip and +`@chat:cloudfl` did not list it. The match itself was fine; what the list held was +not. The recent list was a snapshot taken on the window's first `@` and kept for +the life of the process, and a section cut at eight rows said nothing about the +rest. The conversation in front is still left off on purpose: pointing at the +chat you are typing in is not a reference. diff --git a/internal/manual/chat/attaching-files.md b/internal/manual/chat/attaching-files.md index 6379c3bca..0b3995bd0 100644 --- a/internal/manual/chat/attaching-files.md +++ b/internal/manual/chat/attaching-files.md @@ -559,18 +559,57 @@ Type `@` in the message box. The list under it has three words on the first row, **team**, **chat** and **file**, then teams, conversations, tasks, and files. Typing filters every section at once. `@file:` keeps only files, `@team:` only teams, `@chat:` only conversations. The three words are buttons: a press types that prefix, -the word under the pointer takes a background, and the hint names the key, `click`. +the hint says `click`. + +The conversations on it are the tabs open in this window, including other projects' +tabs, except the one you are typing in. Only the window's own front conversation +is absent for being unnamed with nothing sent, even if its draft names its tab. +Held, restored and side tabs are offered without that check. Then come the twenty +most recent in this project, read once per opening when no read is pending: a new +`@` token or the next letter after `esc`. Openings during a read share one follow-up. For older or other-project conversations +not open here, use `/resume`. The bare +`@` shows eight of them; `@chat:` shows them all and scrolls. Home's box opens the +same list with the same sections and prefixes, leaving no eligible conversation off. +Files use the pinned target or workspace. Locally the foot's `project: ` +names that folder and the sentence opens there. Over `--host`, the unpinned +list offers this machine's files, as in a conversation; the foot and sent +sentence use the far workspace. Choosing a file still puts `@` and the path in the sentence, and nothing is read -until the model asks. Choosing a team puts `●harbor` in the team's colour. Choosing -a conversation puts `@handle`, or a short slug of the title when it has no handle, -and the hint on the row is the full title. A conversation that is in no team is -still on the list. +until the model asks. Choosing a team puts `●harbor` in its current colour, on +home too. Choosing a conversation puts `@handle`, or a short slug of the title when it has no handle, +and the hint on the row is the full title. Conversations in no team are offered too. -After you send, that team mark and that `@handle` stay clickable. A press on the +## After sending a team or conversation mention + +After you send, a team mark such as `●harbor` and a conversation's `@handle` stay +clickable. A press on the team opens the teams page with it selected. A press on the conversation opens it. Over `--host`, against an engine with no teams doors, a press on the team opens the conversations view on it instead. The model is handed a short digest of each one, not the transcript, and the other conversation is not messaged and not woken. The words in your transcript are the words you typed. + +## Searching files and conversations with spaces after @ + +A bare `@` token ends at its first space, so `ask @ben to fix` stays prose and +Enter sends the whole sentence. `@ ` is no token. Only a prefixed search can +hold spaces: `@file:tui3 app`, `@chat:who is` or `@team:har bor`, up to three +spaces and never across a newline. Teams, conversations and files match every +word in any order; only the final word may match by letters in order. Tasks keep +their own scorer for the whole query. + +Recents are read once per opening when no read is pending: a new `@` token or the +next letter after `esc`. Openings during a read share one follow-up; the older answer +cannot settle their search. Letters, caret moves and automatic closes start no read. +After `esc`, a space or punctuation keeps even a bare `@` closed. +Removing the token, including clearing home's box, +ends it; the next `@` reads again. A prefixed multi-word chat search waits with +`looking…` for its fresh read. Several words with no match close a prefixed search +after its catalog has been read. + +An arrival selects the best match unless you chose a row with the arrows since +the query last changed; that choice survives while still offered. A changed query +returns to the best match. On home, clicking a row inserts it immediately. A space +or punctuation after a chosen mention keeps the list closed so you can write on. diff --git a/internal/manual/chat/conversations-and-teams.md b/internal/manual/chat/conversations-and-teams.md index 115e0581b..7ffdfde1f 100644 --- a/internal/manual/chat/conversations-and-teams.md +++ b/internal/manual/chat/conversations-and-teams.md @@ -313,26 +313,78 @@ so. An `@word` that is no member's handle is left as plain text. In the message box, `@` opens the same list files use. Teams are a section of it, each row a coloured dot and the team's name. Conversations are the next section: -the ones open in this window first, then recent ones. A conversation in no team is -on that list. +the ones open in this window first, in the tab strip's order, then recent ones. A +conversation in no team is on that list. The first row is the words **team**, **chat** and **file**. Each is a button with a background under the pointer and a one-line hint (`only teams · click`). A press -types `@team:`, `@chat:` or `@file:`, and the list keeps only that section. Typing -filters every section that is still showing. +types `@team:`, `@chat:` or `@file:`, and the list keeps only that section. -Choosing a team inserts `●harbor` in the team's colour. Choosing a conversation -inserts `@handle`, or a short slug of its title when it has none, and the row's -hint is the full title. After you send, both stay links. A press on the team opens -the teams page with it selected. A press on the conversation opens that conversation. -Over `--host`, against an engine with no teams doors, a press on the team opens the -conversations view on it instead. +Choosing a team inserts `●harbor` in its current colour, on home's box too; +a team edit or adoption is reflected on every box. +Choosing a conversation inserts `@handle`, or a short slug of its title when it +has none, and the row's hint is the full title. After you send, both stay links. +A press on the team opens the teams page with it selected. A press on the +conversation opens that conversation. Over `--host`, against an engine with no +teams doors, a press on the team opens the conversations view on it instead. The model receives a short digest of each reference: for a team, its members, handles, states and recent traffic; for a chat, its title, its state and an excerpt of the last reply. It does not receive the transcript. Mentioning a conversation does not message it and does not wake it. Your transcript keeps the words you typed. +## Which conversations the @ list offers, and why the one I am in is missing + +The `@` list offers every open tab, including other projects, except the chat you +are typing in. Only the window's own front is absent if unnamed with nothing sent, +even if its draft names its tab. Held, restored and side tabs have no such +check. Home and the new-chat page (`+` or `ctrl+t`) leave no eligible chat off: their +sentences open a new one, so the chat behind them is a reference too. + +After the tabs come the twenty most recent conversations in this project. They +are read once per opening when no read is pending: a new `@` token, typed or pasted, +or the next letter after `esc`. Openings during a read share one follow-up; the +older answer cannot settle their search. Letters, caret moves and automatic closes +start no read. A prefixed multi-word chat search waits with `looking…` for its +fresh read. Removing the token ends it; the next `@` asks again. After `esc`, +a space or punctuation keeps even a bare `@` closed. +Over `--host` those are the far machine's. An older conversation, or one in another +project, is offered only if it is already an open tab here; otherwise use `/resume`. +The same transcript appears once, even when its folder has a symlink spelling. + +The bare `@` keeps eight teams and eight conversations beside the tasks and files; +`@chat:` and `@team:` keep up to thirty-two rows in their section and scroll. + +Home has the same sections and prefixes; its first-row words are not buttons, so +type the prefix. Typing or pasting starts the same reads. New rows select the best match unless +you chose a row with the arrows since the query last changed; that choice stays +selected while still offered. A changed query returns to the best match. + +Over `--host`, home's unpinned file list offers this machine's files, as a +conversation's list does. The foot and sent sentence use the far workspace. + +## Searching the @ list with spaces — @chat:who is + +A bare `@` token ends at the first space: `cc @ara on this` is prose, and Enter +sends the whole sentence. `@ ` is no token. Spaces belong to prefixed searches +only: `@chat:who is` finds `who is kim jong il`, `@team:har bor` finds `harbor`, +and `@file:tui3 app` finds `internal/tui3/app.go`. The token may hold up to three +spaces and never crosses a newline, on a conversation's box, home's box and the +new-chat page's box. + +Teams, conversations and files match every word, in any order. A word matches +by prefix, substring or letters in order; only the word still being typed +may match by letters in order. Finished words match whole. `@chat:deploy worker` +finds `Cloudflare worker deploy`. Conversations match their title, handle and +slug, never their transcript. Tasks keep their own scorer for the whole query; +they do not use this every-word rule. + +A prefixed search of several words that matches nothing closes after its catalog +has been read. Before that it stays open with `looking…` and starts the read. +One word that matches nothing says `no team matches`, `no conversation matches` +or `no file matches`. After a chosen mention, a space or punctuation such as +`,`, `.`, `;`, `:`, `!`, `?` or `)` keeps the list closed: you are writing on. + ## Where teams are kept Teams are saved in your profile, in `teams.json`, every time one changes, and never in diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index 0cc0ad8e0..63df9ea87 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -1475,19 +1475,70 @@ the keys row, over the project, with the bulb and the cross, and it appears only 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`. +## Typing @ on home — does the @ list work on home, mention a team, a chat or a file from home's box + +Yes. Type `@` into home's box and the same list a conversation's box opens appears in +home's column, with the same sections and the same prefixes. Its first row is the words +**team**, **chat** and **file**; under them come **teams**, then **conversations**, then +files and folders, ranked as you type, `folder` and `img` tags on the right. Their +root is the pinned target, otherwise this window's workspace; over `--host` the +unpinned list uses this machine's working directory, as a conversation's list does. `@team:` +keeps only teams, `@chat:` only conversations, `@file:` only files, exactly as in a +conversation. +Conversations are every open tab, including other projects — then the twenty +most recent in this project. Only the window's own front conversation is absent for being +unnamed with nothing sent, even if its draft names its tab. Held, restored and side tabs +are offered without that check. Recents are read once per opening when no read is +pending: a new `@` or the next letter after `esc`. Openings during a read share one +follow-up. + +`↑`/`↓` pick and `enter` puts the row in: a team as `●harbor` in the team's colour, a +conversation as `@handle` or a short slug of its title, a path after the `@`. 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. It takes the list first; a later `esc` 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 matches`, or under a +prefix `no team matches`, `no conversation matches` or `no file matches`. On home the three +words on the first row are not buttons: type the prefix. + +## Which project home's @ paths use + +Home's `@` files come from the target pinned with `alt+p` or `/project`, otherwise +this window's file root. On a local window that is the workspace, the folder the +sentence opens in and the one named after `project: ` on the keys row while the +list is up. Over `--host`, the unpinned list offers this machine's files, as a +conversation's list does; the foot and sent sentence use the far machine's +workspace. The project row the cursor stood on before opening the list does not +choose the file root. + +Changing the pinned project while the list is up replaces its files immediately +and starts the new folder's walk. A late answer from the old folder is ignored. +With the list closed, arrows across other projects leave the completed walk +alone; opening the list again on that folder reuses it. + +## Searching home's @ list with spaces and writing after a mention + +On home, a bare `@` token ends at its first space: `ask @ben to fix` stays a +sentence, and Enter starts the conversation with the whole sentence. `@ ` is no +token. Only prefixed searches hold spaces, up to three and never across a newline: +`@chat:who is`, `@team:har bor` and `@file:tui3 app`. Teams, conversations and files +match every word in any order; finished words match whole, and only the final +word may match by letters in order. + +Recents are read once per opening when no read is pending: a new `@` token or the +next letter after `esc`. Openings during a read share one follow-up; the older answer +cannot settle their search. Letters, caret moves and automatic closes start no read. +After `esc`, a space or punctuation keeps even a bare `@` closed. +Clearing the box ends the token and closes its list; +the next `@` reads again and can offer a conversation started in another window. +A prefixed chat search waits with `looking…` for its fresh read. Several words +with no match close it after that answer. + +An arrival selects the best match unless you chose a row with the arrows since +the query last changed; that choice survives while still offered. A changed query +returns to the best match. Clicking a row inserts it immediately. After choosing, +a space or punctuation such as a comma keeps the list closed so you can write on. ## 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 e6358a82e..e4a511557 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -1579,50 +1579,95 @@ the original**. Replaying a conversation starts its attachments collapsed again. Type `@` and codeaf offers one list under the message box. The first row is the words **team**, **chat** and **file**. Under them: **teams**, then **conversations**, -then **tasks**, then **files and folders**. It opens on the bare `@`. You do not have -to type a letter first. It closes on `esc`, on committing, or when the token stops -being one. +then **tasks**, then **files and folders**. It opens on the bare `@`; no letter is +needed first. It closes on `esc`, on committing, or when the token stops being one. **team**, **chat** and **file** are presses. The word under the pointer takes a background, and the hint says `only teams · click`, `only conversations · click` or -`only files · click`. A press types that prefix, `@team:`, `@chat:` or `@file:`, and -the list keeps only that section. Press the same word again and the prefix comes off. -Typing still filters every section that is showing. A command's path argument -(`/image `, `/export `, `/attach `) stays a file list and has no prefix row. - -**Folders are on the list too**, spelled with a trailing slash — `internal/tui3/` — and -marked `folder` on the right the way a picture row is marked `img`. Choosing one puts -its path into your sentence exactly as choosing a file does. It does not choose that -folder as a place; `/folder` is what does that. - -The token is found by walking back from the caret to a space, a newline, or the start -of the message; that run must **begin** with `@`. So an `@` in the middle of a word — -an email address, a Go doc link — never opens the list. - -**What it walks:** the conversation's workspace, or **your own machine's** working -directory over `--host`. **On home** the same list opens over home's box (since -2026-09-22) and walks the folder the next conversation opens in — the `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. - -**The walk runs once per session.** A file created part-way through the conversation -will not appear in the list. - -Ranking puts a prefix match above a substring above a subsequence; the whole path and -the base name are both tried at each tier, the base name a hair below the path. -Inside a tier the earlier match wins, then the shorter path. File hits are capped at -32. The list shows 8 rows, or **14** when a team, a conversation or a task is on it. -Team rows, conversation rows and task rows are each capped at 8. Tasks are searched -to a pool of 40. - -The prefix words are there at once. Teams and the conversations already open in this -window are there at once, from memory. Recent conversations and the file walk arrive -as they are read. With no match the line is `no matches`. A prefix that finds nothing -says `no team matches`, `no conversation matches` or `no file matches`. +`only files · click`. A press types `@team:`, `@chat:` or `@file:` and keeps only +that section. Press the same word again and the prefix comes off. Home's list has +the same prefixes; its first-row words are not buttons, so type the prefix there. +A command's path argument (`/image `, `/export `, `/attach `) stays a file list +and has no prefix row. + +Folders have a trailing slash (`internal/tui3/`) and a `folder` note, like a +picture's `img` note. Choosing one puts its path into the sentence. Use `/folder` +to choose a folder as a place. + +The prefix words, teams and tabs arrive from memory; recent conversations and +files arrive as they are read. Recents are read once per opening when no read is +pending: a new `@` token or the next letter after `esc`. Openings during a read +share one follow-up; the older answer cannot settle their search. Letters, caret +moves and automatic closes start no read. Removing the token ends it. After `esc`, +a space or punctuation keeps even a bare `@` closed. A prefixed multi-word chat +search waits with `looking…` for its fresh answer. +An unread catalog says `looking…`; a read with no match says `no matches`, or +`no team matches`, `no conversation matches` or `no file matches` for a prefix. +Arrivals select the best match unless you chose a row with arrows since the query +changed; that choice survives while offered. A changed query returns to the best +match. On home, clicking inserts the row. + +## Searching the @ list with spaces — which words are a search + +A bare `@` ends at its first space: `ask @ben to fix` stays a sentence and Enter +sends it whole. `@ ` is no token. An `@` in the middle of a word, such as an email +address or a Go doc link, never opens the list. + +Only `@team:`, `@chat:` and `@file:` searches may hold spaces. The token walks back +from the caret over at most three spaces, never past a newline. `@chat:who is` +finds `who is kim jong il`; `@file:internal tui3` finds `internal/tui3/`; +`@team:har bor` finds `harbor`. These searches work in a conversation, on home +and on the new-chat page. + +Teams, conversations and files match every word in any order. A word may match +a prefix, substring or subsequence; only the last word, still being typed, may match +by its letters in order. Finished words match whole. Tasks keep their own scorer +for the whole query rather than matching each word separately. + +A prefixed search of several words that matches nothing closes only after its +catalog has been read; before then the list stays up and starts the read. One word +that matches nothing still says so. A space or punctuation after a chosen mention +keeps the list closed: `@side-chat,` and `@side-chat is a chat` are writing on. + +## Which conversations and how many rows the @ list shows + +The conversations are every tab open in this window, including other projects' +tabs, except the one you are typing in. Only the window's own front conversation +is absent for being unnamed with nothing sent, even if its draft names its tab. +Held, restored and side tabs are offered without that check. Then come the twenty +most recent in this project, read once per opening when no read is pending; +openings during a read share one follow-up. Older conversations and other +projects' saved conversations require `/resume` unless they are already open here. +Home and the new-chat page leave no eligible conversation off: their sentences start a +new conversation, so the one behind them can be a reference too. + +The bare `@` keeps eight teams and eight conversations. `@chat:` and `@team:` +keep up to thirty-two rows and scroll. File hits are capped at 32. The list draws +8 rows, or 14 when a team, conversation or task is present. Task rows are capped +at 8 and searched to a pool of 40. + +## Which folders the @ file list walks and why a new file is missing + +The `@` file list walks the conversation's workspace, or your own machine's +working directory over `--host`. Home's list walks the pinned target, otherwise +this window's file root. On a local window that is the workspace, where the next +conversation opens and what `project:` names while the list is up. Over `--host`, +the unpinned list offers this machine's files, as a conversation's list does; +the foot and sent sentence use the far machine's workspace. The row selected +before opening the list does not choose the file root. +Moving that target with `alt+p` or `/project` replaces the catalog and walks again. Home offers files and folders, never tasks: +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, with paths relative to its root and forward slashes. +It runs once per conversation; a file created part-way through will not appear. +With its list closed, home keeps its completed walk across arrows over other +projects and reuses it when the list next opens in the same folder. + +File ranking puts a prefix above a substring above a subsequence. The whole path +and base name are both tried at each tier, with the base name a hair below the path. +Inside a tier the earlier match wins, then the shorter path. ## What `@` puts into your message @@ -1642,7 +1687,9 @@ says `no team matches`, `no conversation matches` or `no file matches`. - **Under a command's path argument:** the path replaces the argument whole, with no `@` in front, and an image is written into the line like any other file. -**When you send,** every `@` that names a task codeaf already knows about grows +## What happens when I send a task, team or chat mention + +Every `@` that names a task codeaf already knows about grows a pointer-block footnote after the message, one block per task, in token order, deduplicated. A team mark and a chat mark do something else, on the engine: the model is handed a short digest of that team or that conversation, and your diff --git a/internal/tui3/app.go b/internal/tui3/app.go index cccab757f..4c15c31b7 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -3894,17 +3894,25 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { case filesLoadedMsg: if msg.home { - a.homeFilesLoaded(msg.paths) + a.homeFilesLoaded(msg.paths, msg.root) return a, nil } a.comp.all, a.comp.loaded, a.comp.loading = msg.paths, true, false a.fillMentions() - a.comp.rank() + a.comp.refresh(&a.input) a.touch() return a, nil case mentionRecentsMsg: - a.mentionRecentsLoaded(msg.rows) + if msg.read == 0 || msg.read == a.comp.recentsRead { + if msg.read != 0 && a.comp.recentsAgain { + // Openings after this read began need rows from the follow-up, + // so its older answer must leave their search pending. + a.comp.recentsHeld, a.comp.recentsPending, a.comp.recentsAgain = false, false, false + return a, a.loadMentionRecents() + } + a.mentionRecentsLoaded(msg.rows, msg.keys...) + } return a, nil case tasksLoadedMsg: @@ -9097,6 +9105,10 @@ func (a *app) paste(text string) tea.Cmd { // (imagepaste.go's [app.keyboardBox]), because the keystroke fold has to // ask the same question of the same keyboard and get the same answer. box, chips := a.keyboardBox() + was := a.home.comp.open + if box == &a.home.box { + a.prepareHomeCompletion(text) + } wasEmpty := len(box.value) == 0 if box == &a.home.box { a.home.projectPaste.path = "" @@ -9112,6 +9124,9 @@ func (a *app) paste(text string) tea.Cmd { } a.dropLanded(box) a.touch() + if box == &a.home.box { + return a.syncHomeCompletion(was) + } return nil } // A DROPPED PICTURE IS A PICTURE. A terminal writes a drag-and-drop into the @@ -9210,8 +9225,8 @@ func (a *app) listKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { return nil, true case "esc": - // It SEALS the word it was pressed over, so the list does not reappear - // on the next letter of it ([app.dismissLists]). + // It seals a command word, or ends an @ opening so the next letter + // starts a fresh one ([app.dismissLists]). a.dismissLists() a.touch() return nil, true @@ -9269,6 +9284,7 @@ func (a *app) listKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { // no draft can put the caret in both at once (slashchip.go's [slashToken], // files.go's [atToken]). func (a *app) syncLists() tea.Cmd { + opening := a.comp.beginToken(&a.input) if _, bash := session.BashCommand(a.input.String()); bash { a.closeLists() return nil @@ -9301,17 +9317,19 @@ func (a *app) syncLists() tea.Cmd { a.comp.close() return read } - was := a.comp.open + var recents tea.Cmd + if opening { + a.noticeEvent(eventAtOpened) + recents = a.loadMentionRecents() + } a.fillMentions() 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) + if opening { // 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). The recent // conversations ride the same opening (mention.go). - return tea.Batch(a.loadFiles(), a.loadTasks(), a.loadMentionRecents()) + return tea.Batch(a.loadFiles(), a.loadTasks(), recents) } return nil } @@ -9319,18 +9337,22 @@ func (a *app) syncLists() tea.Cmd { func (a *app) closeLists() { a.menu.close() a.comp.close() + if len(a.input.value) == 0 { + // Sending removes the token before closing its list, without another edit. + a.comp.beginToken(&a.input) + } a.harnPick.close() a.skillPick.close() } -// dismissLists is esc over a typed list, which is [app.closeLists] plus the one -// thing esc means that a close does not: the person MEANT the word they are -// typing. Without the seal the list is back on the next keystroke — the overlays -// are derived from the draft, so closing one over a word that still matches is a -// dismissal that lasts exactly until the next letter — and a slash word inside a -// sentence would be uncloseable. See [menu.dismiss]. +// dismissLists is esc over a typed list. A command word is sealed so its list +// stays closed while the person writes on ([menu.dismiss]). An @ list instead +// ends its opening, so the next letter can bring it back with a fresh read. func (a *app) dismissLists() { sealed, at := a.menu.open, a.menu.at + if a.comp.open { + a.comp.dismiss() + } a.closeLists() if sealed { a.menu.dismiss(at) diff --git a/internal/tui3/atcompletion_regression_test.go b/internal/tui3/atcompletion_regression_test.go new file mode 100644 index 000000000..f7aae6d01 --- /dev/null +++ b/internal/tui3/atcompletion_regression_test.go @@ -0,0 +1,1459 @@ +package tui3 + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "reflect" + "runtime" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/Agent-Field/codeaf/internal/tui2/tokens" +) + +// Tracked paths make accidental English matches realistic without depending on +// the review's scratch tests or walking a developer's home. +func completionRepoPaths(t *testing.T) []string { + t.Helper() + out, err := exec.Command("git", "-C", "../..", "ls-files").Output() + if err != nil { + t.Fatal(err) + } + return strings.Fields(string(out)) +} + +func completionBoxEditor(b atBox, a *app) *editor { + if b.name == "home" { + return &a.home.box + } + return &a.input +} + +func assertCompletionSent(t *testing.T, b atBox, a *app, want string) { + t.Helper() + drive(t, a, key("enter")) + if b.name == "home" && a.at(pageHome) { + t.Fatalf("enter did not start home's conversation: %q", b.box(a)) + } + for _, e := range a.entries { + if e.kind == entryUser && strings.Contains(e.text, want) { + return + } + } + t.Fatalf("enter did not send the whole sentence %q; box=%q", want, b.box(a)) +} + +func TestBareAtProseSendsWholeOnEveryBox(t *testing.T) { + paths := completionRepoPaths(t) + for _, b := range atBoxes { + for _, text := range []string{"cc @ara on this", "ask @ben to fix", "ask @ben to fix it", "meet @ to fix", "ask @who is this", "first line\n@ben to fix", "`x @ben to fix"} { + t.Run(b.name+"/"+text, func(t *testing.T) { + a := b.make(t) + c := b.comp(a) + c.all, c.loaded = append([]string(nil), paths...), true + if b.name == "home" { + a.home.walked = a.targetWhere() + } + for _, r := range text { + if r == '\n' { + drive(t, a, key("shift+enter")) + } else { + drive(t, a, key(string(r))) + } + } + if c.open { + t.Errorf("ordinary prose left the @ list open: %q", b.box(a)) + } + assertCompletionSent(t, b, a, text) + }) + } + t.Run(b.name+"/bracketed paste", func(t *testing.T) { + a := b.make(t) + c := b.comp(a) + c.all, c.loaded = append([]string(nil), paths...), true + if b.name == "home" { + a.home.walked = a.targetWhere() + } + drive(t, a, tea.PasteStartMsg{}, tea.PasteMsg{Content: "ask @ben to fix"}, tea.PasteEndMsg{}) + if c.open { + t.Error("bracketed prose paste left the @ list open") + } + assertCompletionSent(t, b, a, "ask @ben to fix") + }) + for _, tc := range []struct{ query, want string }{ + {"@chat:who is", "@who-is-kim-jong-il"}, + {"@team:har bor", "●harbor"}, + {"@file:tui3 app", "@internal/tui3/app.go"}, + } { + t.Run(b.name+"/prefixed/"+tc.query, func(t *testing.T) { + a := b.make(t) + typeInto(t, a, tc.query) + if c := b.comp(a); !c.open || !c.anyHits() { + t.Fatalf("prefixed search is not offering its match: %q", tc.query) + } + drive(t, a, key("enter")) + if got := b.box(a); got != tc.want { + t.Fatalf("prefixed search inserted %q, want %q", got, tc.want) + } + }) + } + } +} + +// A target change must replace catalogs and rows together, before the next +// frame and before an asynchronous walk is allowed to return. +func TestHomeAtTargetChangeNeverLeavesStaleFileRows(t *testing.T) { + lab := newHomeLab(t) + now := lab.pin(time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)) + here, other := lab.workspace("here"), lab.workspace("other") + mine := lab.session("here", "aaaa000000000001", "here conversation", here, now) + theirs := lab.session("other", "aaaa000000000002", "other conversation", other, now) + if err := os.WriteFile(filepath.Join(other, "notes.md"), nil, 0600); err != nil { + t.Fatal(err) + } + a := lab.app(mine) + a.showPage(pageHome) + a.target.where = other + drive(t, a, key("@")) + drive(t, a, key("backspace")) + a.target.where = "" + a.home.point(theirs) + if a.targetWhere() != other { + t.Fatal("fixture did not select the other project's folder") + } + _, cmd := a.Update(key("@")) + c := &a.home.comp + if a.home.walked != here || a.targetWhere() != here { + t.Errorf("opening walks %q while its sentence opens in %q, want %q", a.home.walked, a.targetWhere(), here) + } + for _, line := range c.lines { + if line.file >= len(c.all) { + t.Errorf("home retained file row %d after clearing its %d paths", line.file, len(c.all)) + } + } + func() { + defer func() { + if p := recover(); p != nil { + t.Errorf("home frame panicked after changing targets: %v", p) + } + }() + _ = homeText(a) + }() + spend(t, a, cmd) +} + +func TestFirstSpacedPasteStartsTheCatalogReads(t *testing.T) { + for _, b := range atBoxes[:2] { + for _, query := range []string{"@chat:who is", "@file:internal tui3"} { + t.Run(b.name+"/"+query, func(t *testing.T) { + a := b.make(t) + c := b.comp(a) + c.all, c.loaded, c.loading = nil, false, false + a.comp.recentsHeld, a.comp.recents = false, nil + a.behind, a.prev, a.chatTabs = nil, nil, nil + reads := 0 + a.recentSessions = func() []Session { reads++; return []Session{{File: "/s/kim.jsonl", Title: "who is kim jong il"}} } + _, cmd := a.Update(tea.PasteMsg{Content: query}) + if !c.open { + t.Error("first spaced paste closed before its catalogs were read") + } + if !c.loading { + t.Error("first spaced paste did not start the file walk") + } + if !strings.Contains(b.text(a), "looking…") { + t.Error("unread search did not say looking…") + } + if !a.comp.recentsHeld { + t.Error("first spaced paste did not schedule recents") + } + spend(t, a, cmd) + if reads != 1 || !c.loaded { + t.Fatalf("paste completed reads=%d filesLoaded=%v", reads, c.loaded) + } + if !c.open || !c.anyHits() { + t.Fatalf("loaded search has no open matching list: %q", query) + } + }) + } + } +} + +func TestHomePasteOffersCatalogsAndRefreshesRecents(t *testing.T) { + for _, query := range []string{"@", "@chat:", "@team:h"} { + t.Run(query, func(t *testing.T) { + a := atHomeWithMentions(t) + a.comp.recentsHeld, a.comp.recents = false, nil + reads := 0 + a.recentSessions = func() []Session { reads++; return []Session{{File: "/s/side.jsonl", Title: "side chat"}} } + drive(t, a, tea.PasteStartMsg{}, tea.PasteMsg{Content: query}, tea.PasteEndMsg{}) + c := &a.home.comp + if reads != 1 || !c.loaded { + t.Errorf("home paste read recents=%d filesLoaded=%v", reads, c.loaded) + } + if query != "@chat:" && len(c.teamHits) != 1 { + t.Errorf("home paste offered %d teams", len(c.teamHits)) + } + if query != "@team:h" && len(c.chatHits) != 1 { + t.Errorf("home paste offered %d conversations", len(c.chatHits)) + } + }) + } +} + +func TestRecentArrivalKeepsTheChosenConversation(t *testing.T) { + for _, b := range atBoxes[:2] { + for _, larger := range []bool{false, true} { + name := b.name + "/unchanged" + if larger { + name = b.name + "/larger" + } + t.Run(name, func(t *testing.T) { + a := b.make(t) + typeInto(t, a, "@") + c := b.comp(a) + for i := 0; i < len(c.sel); i++ { + if b.name == "home" { + line, _ := a.home.focusedLine() + if line.comp >= 0 && c.lines[line.comp].chat >= 0 && c.chatHits[c.lines[line.comp].chat].slug == "side-chat" { + break + } + } else if chat, ok := c.chatChoice(); ok && chat.slug == "side-chat" { + break + } + drive(t, a, key("down")) + } + if b.name == "home" { + line, _ := a.home.focusedLine() + if line.comp != c.selLine() { + t.Errorf("home arrow left cursor=%d and completion=%d disagreeing", line.comp, c.selLine()) + } + } + rows := []Session{{File: "/s/side.jsonl", Title: "side chat"}} + if larger { + rows = append([]Session{{File: "/s/new.jsonl", Title: "new conversation"}}, rows...) + } + drive(t, a, mentionRecentsMsg{rows: rows}) + drive(t, a, key("enter")) + if got := b.box(a); got != "@side-chat" { + t.Fatalf("recent arrival changed the chosen conversation to %q", got) + } + }) + } + } +} + +func TestFileArrivalKeepsTheChosenPath(t *testing.T) { + for _, b := range atBoxes[:2] { + t.Run(b.name, func(t *testing.T) { + a := b.make(t) + c := b.comp(a) + c.all, c.loaded = []string{"first.md", "chosen.md"}, true + if b.name == "home" { + a.home.walked = a.targetWhere() + } + typeInto(t, a, "@file:") + if path, _ := c.choice(); path != "first.md" { + t.Fatalf("fixture chose %q", path) + } + drive(t, a, key("down")) + drive(t, a, filesLoadedMsg{paths: []string{"new.md", "first.md", "chosen.md"}, home: b.name == "home"}) + drive(t, a, key("enter")) + if got := b.box(a); got != "@chosen.md" { + t.Fatalf("file arrival changed the chosen path to %q", got) + } + }) + } +} + +func TestRecentKeysTravelOffLoopAndStillDedupeSymlinks(t *testing.T) { + root := t.TempDir() + real, alias := filepath.Join(root, "real"), filepath.Join(root, "alias") + if err := os.Mkdir(real, 0700); err != nil { + t.Fatal(err) + } + if err := os.Symlink(real, alias); err != nil { + t.Fatal(err) + } + for _, n := range []string{"front.jsonl", "side.jsonl", "other.jsonl"} { + if err := os.WriteFile(filepath.Join(real, n), nil, 0600); err != nil { + t.Fatal(err) + } + } + a := mentionApp(t) + emptyMachine(a) + a.file, a.title = filepath.Join(real, "front.jsonl"), "front" + a.stow(Conversation{Agent: &fakeAgent{model: "m"}, SessionFile: filepath.Join(real, "side.jsonl"), Workspace: real}, &aside{title: "side"}) + _ = a.tabsRow(a.width) + a.comp.recentsHeld = false + a.recentSessions = func() []Session { + return []Session{{File: filepath.Join(alias, "front.jsonl"), Title: "front"}, {File: filepath.Join(alias, "side.jsonl"), Title: "side"}, {File: filepath.Join(alias, "other.jsonl"), Title: "other"}, {File: filepath.Join(real, "other.jsonl"), Title: "other duplicate"}} + } + prior := resolveTranscript + walks := 0 + resolveTranscript = func(p string) (string, error) { walks++; return prior(p) } + defer func() { resolveTranscript = prior }() + msg := a.loadMentionRecents()() + t.Logf("off-loop canonical walks=%d", walks) + walks = 0 + a.Update(msg) + if walks != 0 { + t.Errorf("recent message handling performed %d disk walks, want zero", walks) + } + if got := len(a.mentionChats()); got != 2 { + t.Fatalf("canonical conversation catalog has %d rows, want two", got) + } + a.fillHomeMentions() + if got := len(a.home.comp.chats); got != 3 { + t.Fatalf("canonical home catalog has %d rows, want three", got) + } +} + +func TestChosenMentionPunctuationKeepsTheListClosed(t *testing.T) { + for _, b := range atBoxes { + for _, query := range []string{"@chat:side", "@file:app.go"} { + for _, tail := range []string{" ", ",", ".", ";", ":", "!", "?", ")"} { + t.Run(b.name+"/"+query+"/"+tail, func(t *testing.T) { + a := b.make(t) + typeInto(t, a, query) + drive(t, a, key("enter")) + before := b.box(a) + if b.comp(a).open { + t.Fatal("mention was not chosen") + } + typeInto(t, a, tail) + if b.comp(a).open { + t.Fatalf("punctuation reopened the @ list over %q", b.box(a)) + } + if b.box(a) != before+tail { + t.Fatal("punctuation changed the chosen mention") + } + }) + } + } + } +} + +func TestHomeChosenTeamUsesItsColourAtWideAndPhoneWidths(t *testing.T) { + for _, width := range []int{120, 38} { + a := atHomeWithMentions(t) + a.pal = newPalette(tokens.ANSI256, false) + a.width, a.height = width, 30 + a.comp.teams = []mentionTeam{{id: "old", name: "old team", slug: "old-team"}} + typeInto(t, a, "@team:h") + drive(t, a, key("enter")) + lines, _, _, _ := a.homeFrame(width, 30) + pen := a.pal.onPlaces().teamInk(a.wall.teams[0].HueSpec()) + painted := pen(a.home.box.String()) + if painted == a.home.box.String() { + t.Fatal("fixture has no coloured ink") + } + if !strings.Contains(strings.Join(lines, "\n"), painted) { + t.Errorf("home width %d draws the chosen team without its colour", width) + } + } +} + +func TestMentionManualSectionsAreShortAndExplainPrefixedSpaces(t *testing.T) { + for _, name := range []string{"conversations-and-teams.md", "keys.md"} { + raw, err := os.ReadFile(filepath.Join("..", "manual", "chat", name)) + if err != nil { + t.Fatal(err) + } + for _, section := range strings.Split(string(raw), "\n## ")[1:] { + heading, _, _ := strings.Cut(section, "\n") + if !strings.Contains(heading, "@") { + continue + } + if len([]rune(section)) > 2100 { + t.Errorf("%s: @ section %q has %d characters, want about 2000", name, heading, len([]rune(section))) + } + } + if strings.Contains(string(raw), "`@internal tui3`") { + t.Errorf("%s still promises bare spaced searches", name) + } + } +} + +// The moved conversation remains a reference, while a shell named only by +// its draft remains a tab without being offered as a conversation. +func TestMovedDraftTabIsADifferentConversationFromTheRecentRow(t *testing.T) { + lab := newHomeLab(t) + now := lab.pin(time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)) + where := lab.workspace("project") + mine := lab.session("project", "aaaa000000000001", "user asks who kim jong il is", where, now) + a := lab.app(mine) + a.workspace = where + shell := filepath.Join(where, "next", "transcript.jsonl") + if err := os.MkdirAll(filepath.Dir(shell), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(shell, []byte(`{"type":"session","version":1,"id":"next"}`+"\n"), 0600); err != nil { + t.Fatal(err) + } + // An empty workspace asks the start door for its current project, the same + // contract the product uses when a moved window needs a fresh shell. + a.start = func(workspace string) (Conversation, error) { + if workspace == "" { + workspace = where + } + return Conversation{Agent: &switchAgent{fakeAgent: &fakeAgent{model: "m"}}, SessionFile: shell, Workspace: workspace}, nil + } + a.input.setText("see node_modules/@types/node is old") + drive(t, a, a.movedAway("")) + if !a.at(pageHome) { + t.Fatal("moved conversation did not reach home") + } + a.comp.recentsHeld = false + a.recentSessions = func() []Session { return []Session{{File: mine, Title: "user asks who kim jong il is"}} } + typeInto(t, a, "@chat:") + var recent mentionChat + for _, chat := range a.home.comp.chatHits { + if chat.title == "see node_modules/@types/node is old" { + t.Errorf("unsent shell is offered as a conversation: %+v", chat) + } + if chat.title == "user asks who kim jong il is" { + recent = chat + } + } + if recent.key == "" || recent.file != mine { + t.Fatalf("the moved conversation is missing: %+v", a.home.comp.chatHits) + } + if name := a.conversationName(); name != "see node_modules/@types/node is old" { + t.Fatalf("the draft no longer names its tab: %q", name) + } + for _, e := range a.entries { + if e.kind == entryUser { + t.Fatal("the shell already has a sent message") + } + } + t.Logf("moved key=%q file=%q; unsent shell is absent from mentions", recent.key, recent.file) +} + +// The command keeps the hosted rule it was issued under, even if the surface +// switches back to a local conversation before that command executes. +func TestRecentReadCapturesHostedIdentityBeforeItsCommandRuns(t *testing.T) { + a := mentionApp(t) + a.host = "far" + a.comp.recentsHeld = false + a.recentSessions = func() []Session { return []Session{{File: "/home/far/x/../chat.jsonl", Title: "far chat"}} } + cmd := a.loadMentionRecents() + a.host = "" + prior := resolveTranscript + walks := 0 + resolveTranscript = func(p string) (string, error) { walks++; return prior(p) } + defer func() { resolveTranscript = prior }() + msg := cmd() + a.Update(msg) + if walks != 0 { + t.Fatalf("hosted recent read or arrival walked the local disk %d times", walks) + } + if len(a.comp.recents) != 1 || a.comp.recents[0].key != "/home/far/chat.jsonl" { + t.Fatalf("hosted recent keys = %+v", a.comp.recents) + } +} + +func TestHomeAtIgnoresAFileWalkFromItsPreviousTarget(t *testing.T) { + a, first := atHome(t) + _, firstCmd := a.Update(key("@")) + if firstCmd == nil { + t.Fatal("first opening started no walk") + } + a.target.where = t.TempDir() + _, nextCmd := a.Update(key("n")) + next := a.home.walked + if next == first { + t.Fatal("pinning the target did not start another walk") + } + spend(t, a, firstCmd) + if a.home.walked != next || a.home.comp.loaded || len(a.home.comp.all) > 0 { + t.Fatalf("old walk replaced the new target's catalog: %+v", a.home.comp.all) + } + spend(t, a, nextCmd) + if !a.home.comp.loaded { + t.Fatal("the current target's walk did not land") + } +} + +// A local list and its foot must describe the same folder all the way through send. +func TestHomeAtFilesFollowTheSentencesFolder(t *testing.T) { + lab := newHomeLab(t) + now := lab.pin(time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)) + here, other := lab.workspace("here"), lab.workspace("other") + mine := lab.session("here", "aaaa000000000001", "here conversation", here, now) + theirs := lab.session("other", "aaaa000000000002", "other conversation", other, now) + for root, name := range map[string]string{here: "here.md", other: "other.md"} { + if err := os.WriteFile(filepath.Join(root, name), nil, 0600); err != nil { + t.Fatal(err) + } + } + a := lab.app(mine) + a.width = 260 + a.showPage(pageHome) + a.home.point(theirs) + if a.targetWhere() != other { + t.Fatal("fixture did not select the other project") + } + typeInto(t, a, "@file:") + if a.home.walked != here || a.targetWhere() != here { + t.Errorf("list walks %q but sentence opens in %q, want %q", a.home.walked, a.targetWhere(), here) + } + if text := homeText(a); !strings.Contains(text, "here.md") || strings.Contains(text, "other.md") || !strings.Contains(text, targetProjectLead+a.targetProject()) { + t.Errorf("files and project foot disagree:\n%s", text) + } + drive(t, a, key("enter")) + typeInto(t, a, " say only ok") + assertCompletionSent(t, atBoxes[1], a, "@here.md say only ok") + if a.workspace != here { + t.Fatalf("started workspace=%q, want %q", a.workspace, here) + } +} + +// A project chord must replace the paths and the rows before any frame draws. +func TestHomeAtProjectChordRebuildsBeforeTheNextFrame(t *testing.T) { + lab := newHomeLab(t) + now := lab.pin(time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)) + one, two := lab.workspace("one"), lab.workspace("two") + mine := lab.session("one", "aaaa000000000001", "first chat", one, now) + lab.session("two", "aaaa000000000002", "second chat", two, now) + for root, name := range map[string]string{one: "one.md", two: "two.md"} { + if err := os.WriteFile(filepath.Join(root, name), nil, 0600); err != nil { + t.Fatal(err) + } + } + a := lab.app(mine) + a.showPage(pageHome) + a.target.where = one + _, first := a.Update(key("@")) + a.homeFilesLoaded([]string{"one.md"}, one) + _, next := a.Update(key("alt+p")) + if a.target.where != two { + t.Fatal("project chord did not pin the other folder") + } + if a.home.walked != two || a.home.comp.loaded || len(a.home.comp.all) != 0 { + t.Errorf("project chord retained the old catalog: walked=%q paths=%v loaded=%v", a.home.walked, a.home.comp.all, a.home.comp.loaded) + } + for _, line := range a.home.comp.lines { + if line.file >= len(a.home.comp.all) { + t.Errorf("stale file index %d", line.file) + } + } + _ = homeText(a) + spend(t, a, first) + if a.home.walked != two || a.home.comp.loaded || len(a.home.comp.all) != 0 { + t.Error("the old walk replaced the current target") + } + spend(t, a, next) + if text := homeText(a); !strings.Contains(text, "two.md") || strings.Contains(text, "one.md") { + t.Errorf("new target's files did not land:\n%s", text) + } +} + +// Closed-list navigation must preserve a completed walk for the next opening. +func TestHomeAtClosedArrowsReuseTheCompletedWalk(t *testing.T) { + lab := newHomeLab(t) + now := lab.pin(time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)) + one, two := lab.workspace("one"), lab.workspace("two") + mine := lab.session("one", "aaaa000000000001", "first chat", one, now) + other := lab.session("two", "aaaa000000000002", "second chat", two, now) + a := lab.app(mine) + a.width, a.height = 180, 40 + a.showPage(pageHome) + a.home.point(mine) + a.home.walked = a.targetWhere() + a.home.comp.all, a.home.comp.loaded = []string{"notes.md"}, true + root := a.home.walked + a.home.point(other) + a.Update(key("down")) + a.home.point(mine) + a.Update(key("down")) + a.home.point(mine) + _, cmd := a.Update(key("@")) + walks := 0 + for _, msg := range runCmd(cmd) { + if files, ok := msg.(filesLoadedMsg); ok && files.home { + walks++ + } + } + if a.home.walked != root || !a.home.comp.loaded || walks != 0 { + t.Fatalf("cached root=%q reopened=%q loaded=%v new walks=%d, want zero", root, a.home.walked, a.home.comp.loaded, walks) + } +} + +// A warm catalog must wait for the opening's fresh read, including on a paste. +func TestSpacedOpeningRefreshesTheRecentListOnEveryBox(t *testing.T) { + for _, b := range atBoxes { + for _, pasted := range []bool{true, false} { + t.Run(fmt.Sprintf("%s/paste=%v", b.name, pasted), func(t *testing.T) { + a := b.make(t) + a.comp.recentsHeld, a.comp.recentsLoaded, a.comp.recents = false, true, nil + reads := 0 + a.recentSessions = func() []Session { reads++; return []Session{{File: "/s/brand.jsonl", Title: "brand new conversation"}} } + var cmd tea.Cmd + if pasted { + _, cmd = a.Update(tea.PasteMsg{Content: "@chat:brand new"}) + } else { + _, cmd = a.Update(key("@")) + for _, r := range "chat:brand new" { + a.Update(key(string(r))) + } + } + c := b.comp(a) + if !c.open || !strings.Contains(b.text(a), "looking…") { + t.Error("warm opening closed before the fresh recent read answered") + } + if reads != 0 { + t.Fatal("the recent reader ran on the update loop") + } + spend(t, a, cmd) + if reads != 1 || !c.open || len(c.chatHits) != 1 { + t.Fatalf("fresh opening reads=%d open=%v hits=%d, want one read and one row", reads, c.open, len(c.chatHits)) + } + drive(t, a, key("x")) + if reads != 1 { + t.Fatalf("typing in the open list started %d reads", reads) + } + if c.open { + t.Error("a completed unmatched multi-word search stayed open") + } + }) + } + } +} + +// Plain draft rows must not pay for a team catalog they cannot use. +func TestPlainDraftTeamPaintingAllocatesNothing(t *testing.T) { + a := atHomeWithMentions(t) + block := []string{"ordinary draft"} + for _, stale := range []bool{false, true} { + if stale { + a.comp.teams = []mentionTeam{{slug: "old-team"}} + } + if got := testing.AllocsPerRun(20, func() { a.paintDraftMentions(block) }); got != 0 { + t.Errorf("plain row stale=%v allocated %.0f times, want exactly zero", stale, got) + } + } +} + +// Unsent drafts may name tabs, but only sent or named conversations are references. +// The window can judge only its own front's unsent state, not another tab's. +func TestAtListOmitsUnsentShellsOnEveryBox(t *testing.T) { + for _, b := range atBoxes { + for _, kind := range []string{"draft", "sent", "named", "held-draft"} { + t.Run(b.name+"/"+kind, func(t *testing.T) { + a := b.make(t) + a.title, a.openingPrompt, a.entries = "", "", nil + a.file = filepath.Join(t.TempDir(), "shell.jsonl") + if err := os.WriteFile(a.file, []byte(`{"type":"session","version":1,"id":"shell"}`+"\n"), 0600); err != nil { + t.Fatal(err) + } + a.input.setText("unsent shell words") + if kind == "sent" { + a.entries = []entry{{kind: entryUser, text: "sent shell words"}} + } + if kind == "named" { + a.title = "named shell" + } + file := a.file + if kind == "held-draft" { + conv := a.front() + side := a.detachConversation() + side.draft = "unsent shell words" + a.stow(conv, side) + a.file = filepath.Join(t.TempDir(), "new-front.jsonl") + a.input.reset() + } + if b.name != "home" { + a.input.reset() + } + typeInto(t, a, "@chat:") + found := false + for _, chat := range b.comp(a).chatHits { + if chat.file == file { + found = true + } + } + want := kind == "held-draft" || b.name != "conversation" && kind != "draft" + if found != want { + t.Fatalf("%s front offered=%v, want %v; rows=%+v", kind, found, want, b.comp(a).chatHits) + } + }) + } + } +} + +// Retrieval must tell a person why a draft-only tab is missing and what home walks. +func TestMentionManualExplainsSentenceFoldersAndUnsentShells(t *testing.T) { + for name, words := range map[string][]string{ + "home.md": {"sentence opens", "project: ", "nothing sent"}, + "conversations-and-teams.md": {"nothing sent", "fresh read", "looking…"}, + } { + raw, err := os.ReadFile(filepath.Join("..", "manual", "chat", name)) + if err != nil { + t.Fatal(err) + } + for _, word := range words { + if !strings.Contains(string(raw), word) { + t.Errorf("%s does not explain %q", name, word) + } + } + } +} + +// A hosted list offers this machine's files, while its sentence opens on the far one. +func TestHomeAtHostedFilesUseLocalRootAndSentenceUsesFarWorkspace(t *testing.T) { + a, root := atHome(t) + far := "/far/project" + a.target.where = "" + a.host, a.workspace, a.localRoot = "far", far, root + a.width = 260 + drive(t, a, key("@")) + if a.home.walked != root || a.home.walked != a.pathRoot() { + t.Fatalf("hosted list walks %q, want local root %q", a.home.walked, root) + } + walk := a.loadFiles() + if walk == nil { + t.Fatal("the conversation's file walk did not start") + } + conversation := walk().(filesLoadedMsg) + if got, want := strings.Join(a.home.comp.all, "\n"), strings.Join(conversation.paths, "\n"); got != want { + t.Fatalf("hosted home files=%q, conversation files=%q", got, want) + } + if a.targetWhere() != far { + t.Errorf("hosted sentence targets %q, want far workspace %q", a.targetWhere(), far) + } + if text := homeText(a); !strings.Contains(text, "notes.md") || !strings.Contains(text, targetProjectLead+"far:"+far) { + t.Errorf("hosted list must show local files and the foot must name the far workspace:\n%s", text) + } + drive(t, a, key("n"), key("enter")) + typeInto(t, a, " say only ok") + assertCompletionSent(t, atBoxes[1], a, "@notes.md say only ok") + if a.workspace != far { + t.Fatalf("hosted sentence opened in %q, want far workspace %q", a.workspace, far) + } +} + +// A ready walk from another target cannot dismiss a paste before the new walk. +func TestHomeAtSpacedFilePasteWaitsForItsNewFolder(t *testing.T) { + a, old := atHome(t) + a.home.walked = old + a.home.comp.all, a.home.comp.loaded = []string{"old.md"}, true + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "new note.md"), nil, 0600); err != nil { + t.Fatal(err) + } + a.target.where = root + _, cmd := a.Update(tea.PasteMsg{Content: "@file:new note"}) + if !a.home.comp.open || !a.home.comp.loading || a.home.walked != root { + t.Fatalf("new-folder paste open=%v loading=%v walked=%q", a.home.comp.open, a.home.comp.loading, a.home.walked) + } + spend(t, a, cmd) + drive(t, a, key("enter")) + if got := a.home.box.String(); got != "@new note.md" { + t.Fatalf("new-folder paste chose %q", got) + } +} + +// A paste recognizes an opening even when its ready section closes at once. +func TestUnmatchedPrefixedPasteStillReadsRecentsOnEveryBox(t *testing.T) { + for _, b := range atBoxes { + for _, query := range []string{"@team:no such", "@file:no such"} { + t.Run(b.name+"/"+query, func(t *testing.T) { + a := b.make(t) + c := b.comp(a) + c.all, c.loaded = []string{"unrelated.md"}, true + if b.name == "home" { + a.home.walked = a.homeCompletionRoot() + } + a.comp.recentsHeld, a.comp.recentsLoaded = false, true + reads := 0 + a.recentSessions = func() []Session { reads++; return nil } + _, cmd := a.Update(tea.PasteMsg{Content: query}) + if c.open { + t.Error("a ready unmatched multi-word section stayed open") + } + if reads != 0 { + t.Fatal("the reader ran on the update loop") + } + spend(t, a, cmd) + if reads != 1 { + t.Fatalf("pasted opening read recents %d times, want once", reads) + } + }) + } + } +} + +// Removing a newline can recognize the same opening as a printable key or paste. +func TestBackspaceOpeningRefreshesRecentsOnEveryBox(t *testing.T) { + for _, b := range atBoxes { + t.Run(b.name, func(t *testing.T) { + a := b.make(t) + c := b.comp(a) + c.recentsLoaded = true + a.comp.recentsHeld, a.comp.recentsLoaded, a.comp.recents = false, true, nil + completionBoxEditor(b, a).setText("@chat:brand new\n") + reads := 0 + a.recentSessions = func() []Session { reads++; return []Session{{File: "/s/brand.jsonl", Title: "brand new conversation"}} } + _, cmd := a.Update(key("backspace")) + if !c.open || !strings.Contains(b.text(a), "looking…") { + t.Error("backspace opening did not wait for its fresh recent read") + } + spend(t, a, cmd) + if reads != 1 || !c.open || len(c.chatHits) != 1 { + t.Fatalf("backspace reads=%d open=%v hits=%d", reads, c.open, len(c.chatHits)) + } + }) + } +} + +// A chosen mention in a closed box does not make arrows catalog edits. +func TestClosedHomeArrowsLeaveMentionCatalogsAlone(t *testing.T) { + a, root := atHome(t) + a.home.walked = root + a.home.comp.all, a.home.comp.loaded = []string{"notes.md"}, true + a.home.box.setText("@notes.md") + a.home.comp.done = "notes.md" + a.home.comp.teams = []mentionTeam{{id: "kept", name: "kept", slug: "kept"}} + a.Update(key("down")) + if got := a.home.comp.teams; len(got) != 1 || got[0].id != "kept" { + t.Fatalf("closed arrow copied a catalog: %+v", got) + } +} + +// An opening belongs to the token, even after its unmatched rows disappear. +func TestOneRecentReadAndNoticePerAtTokenOnEveryBox(t *testing.T) { + for _, b := range atBoxes { + for _, scenario := range []string{"@chat:no such words", "@chat:unique words", "@no such words", "paste and caret", "remove and replace", "cut and replace", "send and replace", "different at"} { + t.Run(b.name+"/"+scenario, func(t *testing.T) { + a := b.make(t) + a.comp.recentsHeld, a.comp.recentsLoaded, a.comp.recents = false, true, nil + reads, notices := 0, 0 + a.recentSessions = func() []Session { + reads++ + return []Session{{File: filepath.Join(a.workspace, "unique.jsonl"), Title: "unique words"}} + } + edit := func(msg tea.Msg) { + a.notices.seen[eventAtOpened] = false + drive(t, a, msg) + if a.notices.seen[eventAtOpened] { + notices++ + } + } + typeWords := func(words string) { + for _, r := range words { + edit(key(string(r))) + } + } + want := 1 + switch scenario { + case "paste and caret": + edit(tea.PasteMsg{Content: "@chat:no such words"}) + edit(key("left")) + edit(key("right")) + case "remove and replace": + typeWords("@chat:x") + for b.box(a) != "" { + edit(key("backspace")) + } + typeWords("@chat:y") + want = 2 + case "different at": + typeWords("@chat:x @chat:y") + want = 2 + case "cut and replace": + typeWords("@chat:x") + edit(key("ctrl+u")) + typeWords("@chat:y") + want = 2 + case "send and replace": + typeWords("@chat:no such words") + edit(key("enter")) + if b.box(a) != "" { + t.Fatalf("send kept its token in the box: %q", b.box(a)) + } + if b.name == "home" { + a.showPage(pageHome) + } + typeWords("@chat:y") + want = 2 + default: + typeWords(scenario) + } + t.Logf("reads=%d opening notices=%d, want %d of each", reads, notices, want) + if reads != want || notices != want { + t.Errorf("one opening per token: reads=%d notices=%d, want %d", reads, notices, want) + } + }) + } + } +} + +// Escape ends an opening without deleting its token; the next letter reads anew. +func TestEscEndsAtOpeningAndNextLetterReadsFreshOnEveryBox(t *testing.T) { + for _, b := range atBoxes { + for _, query := range []string{"@", "@chat:"} { + t.Run(b.name+"/"+query, func(t *testing.T) { + a := b.make(t) + a.comp.recentsHeld, a.comp.recentsLoaded, a.comp.recents = false, true, nil + reads, notices := 0, 0 + rows := []Session{{File: filepath.Join(a.workspace, "old.jsonl"), Title: "old conversation"}} + a.recentSessions = func() []Session { reads++; return rows } + edit := func(msg tea.Msg) { + a.notices.seen[eventAtOpened] = false + drive(t, a, msg) + if a.notices.seen[eventAtOpened] { + notices++ + } + } + for _, r := range query { + edit(key(string(r))) + } + c := b.comp(a) + if !c.open || reads != 1 || notices != 1 { + t.Fatalf("initial opening: open=%v reads=%d notices=%d", c.open, reads, notices) + } + edit(key("esc")) + if c.open || b.box(a) != query || reads != 1 || notices != 1 { + t.Fatalf("escape: open=%v box=%q reads=%d notices=%d", c.open, b.box(a), reads, notices) + } + rows = []Session{{File: filepath.Join(a.workspace, "cloudflare.jsonl"), Title: "Cloudflare worker deploy"}} + edit(key("c")) + if !c.open || reads != 2 || notices != 2 { + t.Fatalf("letter after escape: open=%v reads=%d notices=%d, want 2 of each", c.open, reads, notices) + } + edit(key("l")) + if !c.open || reads != 2 || notices != 2 || len(c.chatHits) != 1 || c.chatHits[0].title != rows[0].Title { + t.Fatalf("next letter: open=%v reads=%d notices=%d chats=%+v", c.open, reads, notices, c.chatHits) + } + edit(key("esc")) + edit(key("o")) + if !c.open || reads != 3 || notices != 3 { + t.Fatalf("another dismissal: open=%v reads=%d notices=%d, want 3 of each", c.open, reads, notices) + } + edit(key("esc")) + edit(key(" ")) + if c.open || reads != 3 || notices != 3 { + t.Fatalf("separator after escape: open=%v reads=%d notices=%d", c.open, reads, notices) + } + for _, r := range "@chat:cl" { + edit(key(string(r))) + } + if !c.open || reads != 4 || notices != 4 { + t.Fatalf("new token after escape and space: open=%v reads=%d notices=%d, want 4 of each", c.open, reads, notices) + } + t.Logf("four openings, reads=%d opening notices=%d", reads, notices) + }) + } + } +} + +// A new token shares the pending walk's follow-up, whose newer catalog must +// survive an older answer arriving again after the fresh rows have landed. +func TestANewAtTokenWaitsForTheWalkInFlightAndIsSettledByItsFollowUp(t *testing.T) { + for _, b := range atBoxes { + t.Run(b.name, func(t *testing.T) { + a := b.make(t) + a.comp.recentsHeld, a.comp.recentsLoaded, a.comp.recents = false, true, nil + var reads, active, maximum atomic.Int32 + started := make(chan int32, 1) + release := make(chan struct{}) + answers := make(chan tea.Msg, 3) + var workers sync.WaitGroup + defer func() { close(release); workers.Wait() }() + a.recentSessions = func() []Session { + read := reads.Add(1) + n := active.Add(1) + for old := maximum.Load(); n > old; old = maximum.Load() { + if maximum.CompareAndSwap(old, n) { + break + } + } + started <- read + <-release + active.Add(-1) + if read == 1 { + return []Session{{File: filepath.Join(a.workspace, "old.jsonl"), Title: "old words"}} + } + return []Session{{File: filepath.Join(a.workspace, "unique.jsonl"), Title: "unique words"}} + } + scheduled := 0 + start := func(cmd tea.Cmd) { + for _, read := range completionRecentCommands(cmd) { + scheduled++ + workers.Add(1) + go func() { defer workers.Done(); answers <- read() }() + select { + case <-started: + case <-time.After(5 * time.Second): + t.Fatal("scheduled recent reader did not start") + } + } + } + land := func() (tea.Msg, tea.Cmd) { + release <- struct{}{} + select { + case msg := <-answers: + _, cmd := a.Update(msg) + return msg, cmd + case <-time.After(5 * time.Second): + t.Fatal("released reader did not answer") + return nil, nil + } + } + _, first := a.Update(tea.PasteMsg{Content: "@chat:old words"}) + start(first) + if scheduled != 1 || reads.Load() != 1 || active.Load() != 1 { + t.Fatalf("first token scheduled=%d reads=%d active=%d, want 1 of each", scheduled, reads.Load(), active.Load()) + } + for b.box(a) != "" { + _, cmd := a.Update(key("backspace")) + start(cmd) + } + _, second := a.Update(tea.PasteMsg{Content: "@chat:unique words"}) + start(second) + c := b.comp(a) + if scheduled != 1 || reads.Load() != 1 || active.Load() != 1 || maximum.Load() != 1 { + t.Fatalf("new token overlapped the pending walk: scheduled=%d reads=%d active=%d peak=%d", scheduled, reads.Load(), active.Load(), maximum.Load()) + } + if !a.comp.recentsPending || !a.comp.recentsAgain || c.recentsLoaded || !c.open || !strings.Contains(b.text(a), "looking…") { + t.Fatal("new token did not wait for the pending walk's follow-up") + } + old, follow := land() + if !a.comp.recentsPending || a.comp.recentsLoaded || c.recentsLoaded || len(a.comp.recents) != 0 || len(c.chatHits) != 0 || !c.open || !strings.Contains(b.text(a), "looking…") { + t.Fatal("answer begun before the newest opening settled its search as fresh") + } + start(follow) + if scheduled != 2 || reads.Load() != 2 || active.Load() != 1 || maximum.Load() != 1 { + t.Fatalf("first answer did not start exactly one follow-up: scheduled=%d reads=%d active=%d peak=%d", scheduled, reads.Load(), active.Load(), maximum.Load()) + } + _, extra := land() + if len(completionRecentCommands(extra)) != 0 || a.comp.recentsPending || !a.comp.recentsLoaded || !c.recentsLoaded || !c.open || len(c.chatHits) != 1 || c.chatHits[0].title != "unique words" { + t.Fatalf("follow-up did not settle the new token: pending=%v open=%v rows=%+v", a.comp.recentsPending, c.open, c.chatHits) + } + _, stale := a.Update(old) + if len(completionRecentCommands(stale)) != 0 || a.comp.recentsPending || !c.recentsLoaded || !c.open || len(a.comp.recents) != 1 || a.comp.recents[0].title != "unique words" || len(c.chatHits) != 1 || c.chatHits[0].title != "unique words" { + t.Fatalf("old answer displaced the newer catalog: pending=%v open=%v rows=%+v", a.comp.recentsPending, c.open, c.chatHits) + } + if reads.Load() != 2 || active.Load() != 0 || maximum.Load() != 1 { + t.Fatalf("settled reads=%d active=%d peak=%d, want 2, 0, 1", reads.Load(), active.Load(), maximum.Load()) + } + t.Logf("settled: reads=%d peak=%d catalog=%q", reads.Load(), maximum.Load(), c.chatHits[0].title) + }) + } +} + +// Emptying home's box must end its token before another window changes recents. +func TestHomeEmptyBoxEndsAtTokenAndNextOpeningReadsFreshRows(t *testing.T) { + a := atBoxes[1].make(t) + a.comp.recentsHeld, a.comp.recents = false, nil + reads := 0 + rows := []Session{{File: filepath.Join(a.workspace, "old.jsonl"), Title: "old conversation"}} + a.recentSessions = func() []Session { reads++; return rows } + typeInto(t, a, "@chat:") + for a.home.box.String() != "" { + drive(t, a, key("backspace")) + } + if a.home.comp.open { + t.Error("home kept the list open after backspace emptied its box") + } + rows = []Session{{File: filepath.Join(a.workspace, "puff.jsonl"), Title: "puff conversation"}} + typeInto(t, a, "@chat:puff") + if reads != 2 || len(a.home.comp.chatHits) != 1 || a.home.comp.chatHits[0].title != "puff conversation" { + t.Fatalf("second opening reads=%d rows=%+v, want a fresh puff conversation", reads, a.home.comp.chatHits) + } + drive(t, a, key("enter")) + if a.home.box.String() != "@puff-conversation" { + t.Fatalf("enter inserted %q", a.home.box.String()) + } +} + +// A default cursor is not a person's choice; fresh data should improve its match. +func TestRecentArrivalSelectsBestMatchUntilPersonChooses(t *testing.T) { + for _, b := range atBoxes[:2] { + for _, arrowed := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/arrowed=%v", b.name, arrowed), func(t *testing.T) { + a := b.make(t) + loose := Session{File: filepath.Join(a.workspace, "swan.jsonl"), Title: "what color is a swan heron"} + best := Session{File: filepath.Join(a.workspace, "heron.jsonl"), Title: "heron conversation"} + a.mentionRecentsLoaded([]Session{loose}) + a.recentSessions = func() []Session { return []Session{best, loose} } + _, cmd := a.Update(tea.PasteMsg{Content: "@chat:heron"}) + c := b.comp(a) + if chosen, ok := c.chatChoice(); !ok || chosen.file != loose.File { + t.Fatalf("stale catalog did not initially offer the loose match: %+v", c.chatHits) + } + if arrowed { + drive(t, a, key("down")) + } + spend(t, a, cmd) + want := "@heron-conversation" + if arrowed { + want = "@what-color-is-a-swan-heron" + } + if c.chatHits[0].file != best.File { + t.Fatal("fresh data did not rank the best match first") + } + drive(t, a, key("enter")) + if got := b.box(a); got != want { + t.Fatalf("arrival inserted %q, want %q", got, want) + } + }) + } + } +} + +// Changing a query gives the best match the cursor again, including on file arrivals. +func TestQueryChangeEndsTheChosenRowOnEveryBox(t *testing.T) { + for _, b := range atBoxes[:2] { + t.Run(b.name, func(t *testing.T) { + a := b.make(t) + c := b.comp(a) + c.all, c.loaded = []string{"heron-a.md", "heron-b.md"}, true + if b.name == "home" { + a.home.walked = a.homeCompletionRoot() + } + typeInto(t, a, "@file:heron") + drive(t, a, key("down")) + drive(t, a, key("-")) + drive(t, a, filesLoadedMsg{paths: []string{"heron-.md", "heron-a.md", "heron-b.md"}, home: b.name == "home"}) + drive(t, a, key("enter")) + if got := b.box(a); got != "@heron-.md" { + t.Fatalf("query change retained the old choice: %q", got) + } + }) + } +} + +// A queued first user message remains mentionable when its untitled tab is held. +func TestFirstQueuedFollowUpRemainsMentionableWhenHeld(t *testing.T) { + _, a := queuedConversation(t) + file := filepath.Join(t.TempDir(), "first-follow.jsonl") + a.file, a.title, a.openingPrompt, a.entries = file, "", "", nil + a.input.setText("first sent follow-up") + drive(t, a, key("ctrl+enter"), streamClosedMsg{gen: a.gen}) + sent := false + for _, e := range a.entries { + if e.kind == entryUser && e.text == "first sent follow-up" { + sent = true + } + } + if !sent { + t.Fatal("the queued turn did not record its sent user message") + } + a.input.setText("a later unsent draft") + conv := a.front() + side := a.detachConversation() + a.stow(conv, side) + a.file = filepath.Join(t.TempDir(), "new-front.jsonl") + a.input.reset() + for _, chat := range a.mentionChatsExcept("") { + if chat.file == file { + return + } + } + t.Fatalf("held queued conversation was omitted: title=%q opening=%q draft=%q", side.title, side.openingPrompt, side.draft) +} + +// Only the in-memory front is subject to the unsent-shell rule. +func TestOtherTabsRemainMentionableWithoutTitlesOrOpenings(t *testing.T) { + a := mentionApp(t) + file := filepath.Join(t.TempDir(), "held.jsonl") + a.stow(Conversation{Agent: &fakeAgent{}, SessionFile: file}, &aside{draft: "held draft"}) + for _, chat := range a.mentionChatsExcept("") { + if chat.file == file { + return + } + } + t.Fatal("an unlabelled held tab was filtered by the front's unsent-shell rule") +} + +// The manual must explain token openings, deliberate choices and the front-only omission. +func TestMentionManualExplainsTokenOpeningsAndDeliberateChoices(t *testing.T) { + for _, name := range []string{"conversations-and-teams.md", "home.md", "keys.md", "attaching-files.md"} { + raw, err := os.ReadFile(filepath.Join("..", "manual", "chat", name)) + if err != nil { + t.Fatal(err) + } + for _, words := range []string{"once per", "token", "best match", "chose", "window's own"} { + if !strings.Contains(string(raw), words) { + t.Errorf("%s does not explain %q", name, words) + } + } + } +} + +// Only recent-reader commands are driven here, so unrelated clocks and file walks +// cannot obscure the exact number of reads admitted by the real edit path. +func completionRecentCommands(cmd tea.Cmd) []tea.Cmd { + if cmd == nil { + return nil + } + name := runtime.FuncForPC(reflect.ValueOf(cmd).Pointer()).Name() + if strings.Contains(name, ".loadMentionRecents.") { + return []tea.Cmd{cmd} + } + if !strings.Contains(name, ".Batch.") { + return nil + } + var out []tea.Cmd + for _, child := range cmd().(tea.BatchMsg) { + out = append(out, completionRecentCommands(child)...) + } + return out +} + +// Blocking the reader makes overlap and freshness counts independent of disk +// speed. A burst owes one follow-up, begun after its last opening, not one per edit. +func TestRecentWalksNeverOverlapAndCoalesceOpeningBursts(t *testing.T) { + for _, b := range atBoxes { + for _, scenario := range []string{"three tokens", "sixty-four tokens", "escape and eight letters", "one pasted token", "replace pasted token"} { + t.Run(b.name+"/"+scenario, func(t *testing.T) { + a := b.make(t) + a.comp.recentsHeld, a.comp.recentsLoaded, a.comp.recents = false, true, nil + var openings, active, maximum, reads atomic.Int32 + started := make(chan int32, 128) + release := make(chan struct{}) + answers := make(chan tea.Msg, 128) + var workers sync.WaitGroup + defer func() { close(release); workers.Wait() }() + a.recentSessions = func() []Session { + reads.Add(1) + n := active.Add(1) + for old := maximum.Load(); n > old; old = maximum.Load() { + if maximum.CompareAndSwap(old, n) { + break + } + } + opening := openings.Load() + started <- opening + <-release + active.Add(-1) + return []Session{{File: filepath.Join(a.workspace, "latest.jsonl"), Title: fmt.Sprintf("latest words at opening %d", opening)}} + } + scheduled := 0 + start := func(cmd tea.Cmd) { + for _, read := range completionRecentCommands(cmd) { + scheduled++ + workers.Add(1) + go func() { defer workers.Done(); answers <- read() }() + select { + case opening := <-started: + if opening != openings.Load() { + t.Fatalf("read began at opening %d, newest is %d", opening, openings.Load()) + } + case <-time.After(5 * time.Second): + t.Fatal("scheduled recent reader did not start") + } + } + } + edit := func(msg tea.Msg) { + a.notices.seen[eventAtOpened] = false + _, cmd := a.Update(msg) + if a.notices.seen[eventAtOpened] { + openings.Add(1) + } + start(cmd) + } + wantOpenings := int32(1) + switch scenario { + case "three tokens", "sixty-four tokens": + wantOpenings = 3 + if scenario == "sixty-four tokens" { + wantOpenings = 64 + } + for i := int32(0); i < wantOpenings; i++ { + if i > 0 { + edit(key("backspace")) + } + edit(key("@")) + } + case "escape and eight letters": + edit(key("@")) + for i := 0; i < 8; i++ { + edit(key("esc")) + edit(key("c")) + } + wantOpenings = 9 + case "one pasted token", "replace pasted token": + edit(tea.PasteMsg{Content: "@chat:latest words"}) + if scenario == "replace pasted token" { + for b.box(a) != "" { + edit(key("backspace")) + } + edit(tea.PasteMsg{Content: "@chat:latest words"}) + wantOpenings = 2 + } + } + t.Logf("before answers: reads=%d active=%d peak=%d openings=%d", reads.Load(), active.Load(), maximum.Load(), openings.Load()) + if scheduled != 1 || maximum.Load() != 1 || openings.Load() != wantOpenings { + t.Fatalf("burst scheduled=%d peak=%d openings=%d, want 1, 1, %d", scheduled, maximum.Load(), openings.Load(), wantOpenings) + } + if !a.comp.recentsPending || b.comp(a).recentsLoaded { + t.Fatal("blocked read was treated as settled") + } + if strings.Contains(b.box(a), " ") && (!b.comp(a).open || !strings.Contains(b.text(a), "looking…")) { + t.Fatal("spaced search did not wait for its fresh catalog") + } + land := func() (tea.Msg, tea.Cmd) { + release <- struct{}{} + select { + case msg := <-answers: + _, cmd := a.Update(msg) + return msg, cmd + case <-time.After(5 * time.Second): + t.Fatal("released reader did not answer") + return nil, nil + } + } + old, follow := land() + wantReads := int32(1) + if wantOpenings > 1 { + wantReads = 2 + if !a.comp.recentsPending || a.comp.recentsLoaded || b.comp(a).recentsLoaded || len(a.comp.recents) != 0 { + t.Fatal("read begun before the newest opening settled its search") + } + start(follow) + if scheduled != 2 { + t.Fatalf("burst scheduled %d reads after first answer, want 2", scheduled) + } + if _, cmd := a.Update(old); len(completionRecentCommands(cmd)) != 0 || !a.comp.recentsPending { + t.Fatal("duplicate old answer disturbed the follow-up") + } + _, extra := land() + if len(completionRecentCommands(extra)) != 0 { + t.Fatal("settled burst scheduled a third read") + } + } else if len(completionRecentCommands(follow)) != 0 { + t.Fatal("one opening scheduled a follow-up") + } + a.Update(old) + wantTitle := fmt.Sprintf("latest words at opening %d", wantOpenings) + if a.comp.recentsPending || !a.comp.recentsLoaded || len(a.comp.recents) != 1 || a.comp.recents[0].title != wantTitle { + t.Fatalf("final catalog is not fresh: pending=%v rows=%+v", a.comp.recentsPending, a.comp.recents) + } + if reads.Load() != wantReads || maximum.Load() != 1 || active.Load() != 0 { + t.Fatalf("settled reads=%d peak=%d active=%d, want %d, 1, 0", reads.Load(), maximum.Load(), active.Load(), wantReads) + } + t.Logf("settled: reads=%d peak=%d final catalog=%q", reads.Load(), maximum.Load(), wantTitle) + }) + } + } +} + +// A supplied catalog with its door held shut remains authoritative across openings. +func TestHeldRecentCatalogDoesNotScheduleWalksForOpeningBursts(t *testing.T) { + for _, b := range atBoxes { + t.Run(b.name, func(t *testing.T) { + a := b.make(t) + reads := 0 + a.recentSessions = func() []Session { reads++; return nil } + for i := 0; i < 3; i++ { + drive(t, a, key("@"), key("backspace")) + } + if reads != 0 || a.comp.recentsRead != 0 || a.comp.recentsPending { + t.Fatalf("held catalog read=%d generation=%d pending=%v", reads, a.comp.recentsRead, a.comp.recentsPending) + } + }) + } +} + +// Moving between boxes must not create a second owner of the pending catalog. +func TestAtBoxesShareOnePendingRecentCatalog(t *testing.T) { + a := atBoxes[2].make(t) + a.cancelChatStart() + a.comp.recentsHeld, a.comp.recents = false, nil + reads := 0 + a.recentSessions = func() []Session { + reads++ + return []Session{{File: filepath.Join(a.workspace, "latest.jsonl"), Title: fmt.Sprintf("latest words %d", reads)}} + } + _, first := a.Update(tea.PasteMsg{Content: "@chat:latest words"}) + commands := completionRecentCommands(first) + if len(commands) != 1 { + t.Fatalf("conversation scheduled %d readers, want 1", len(commands)) + } + a.showPage(pageHome) + _, home := a.Update(tea.PasteMsg{Content: "@chat:latest words"}) + if len(completionRecentCommands(home)) != 0 { + t.Fatal("home started a second reader while the conversation's was pending") + } + a.showPage(pageNone) + a.openChatStart() + if !a.startingChat() { + t.Fatal("fixture did not open the start page") + } + _, start := a.Update(tea.PasteMsg{Content: "@chat:latest words"}) + if len(completionRecentCommands(start)) != 0 { + t.Fatal("start started a second reader while the conversation's was pending") + } + _, next := a.Update(commands[0]()) + commands = completionRecentCommands(next) + if len(commands) != 1 || !a.comp.recentsPending || a.comp.recentsLoaded || a.home.comp.recentsLoaded { + t.Fatal("three boxes did not share one pending follow-up") + } + _, extra := a.Update(commands[0]()) + if len(completionRecentCommands(extra)) != 0 || reads != 2 || a.comp.recentsPending || !a.comp.recentsLoaded { + t.Fatalf("shared catalog did not settle after two reads: reads=%d pending=%v", reads, a.comp.recentsPending) + } + if len(a.comp.chatHits) != 1 || a.comp.chatHits[0].title != "latest words 2" { + t.Fatalf("start did not receive the shared fresh catalog: %+v", a.comp.chatHits) + } + // Leaving home closes its list, so it copies the shared rows when needed again. + a.fillHomeMentions() + for _, chat := range a.home.comp.chats { + if chat.title == "latest words 2" && a.home.comp.recentsLoaded { + return + } + } + t.Fatalf("home did not copy the shared fresh catalog: %+v", a.home.comp.chats) +} + +// Escape seals even the empty query over a separator; letters still begin one +// fresh opening, and another letter in that opening starts no additional read. +func TestEscThenSeparatorKeepsBareAndNamedAtListsClosed(t *testing.T) { + for _, b := range atBoxes { + for _, query := range []string{"@", "@chat:ki"} { + for _, after := range []string{" ", ",", ".", ";", ":", "!", "?", "(", ")", "'", "c", "é"} { + t.Run(b.name+"/"+query+"/"+after, func(t *testing.T) { + a := b.make(t) + a.comp.recentsHeld = false + reads, notices := 0, 0 + a.recentSessions = func() []Session { reads++; return nil } + edit := func(msg tea.Msg) { + a.notices.seen[eventAtOpened] = false + drive(t, a, msg) + if a.notices.seen[eventAtOpened] { + notices++ + } + } + for _, r := range query { + edit(key(string(r))) + } + edit(key("esc")) + if b.comp(a).open || b.box(a) != query { + t.Fatal("escape changed the token or left the list open") + } + edit(key(after)) + wantOpen := after == "c" || after == "é" + wantReads := 1 + if wantOpen { + wantReads++ + } + if b.comp(a).open != wantOpen || reads != wantReads || notices != wantReads { + t.Fatalf("after %q: open=%v reads=%d notices=%d, want open=%v reads/notices=%d", after, b.comp(a).open, reads, notices, wantOpen, wantReads) + } + edit(key("c")) + if b.comp(a).open != wantOpen || reads != wantReads || notices != wantReads { + t.Fatal("following letter changed the separator or read again within an opening") + } + }) + } + } + } +} diff --git a/internal/tui3/atspaces_test.go b/internal/tui3/atspaces_test.go new file mode 100644 index 000000000..8b196c6b5 --- /dev/null +++ b/internal/tui3/atspaces_test.go @@ -0,0 +1,273 @@ +package tui3 + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// ── PREFIXED SEARCHES MAY HAVE SPACES, ON EVERY BOX ────── + +// A prefixed token walks back over spaces to the nearest `@` beginning a word, and +// no further than three of them or a newline. +func TestAtTokenHoldsSpaces(t *testing.T) { + for _, tc := range []struct { + draft string + at int + query string + ok bool + }{ + {"@chat:who is", 0, "chat:who is", true}, + {"see @who is kim jong", 0, "", false}, + {"see @one two three four five", 0, "", false}, + {"mail foo@bar.com now", 0, "", false}, + {"@parser\nand then", 0, "", false}, + {"@", 0, "", true}, + {"plain words", 0, "", false}, + } { + value := []rune(tc.draft) + at, query, ok := atToken(value, len(value)) + if ok != tc.ok || (ok && (at != tc.at || query != tc.query)) { + t.Errorf("atToken(%q) = %d %q %v, want %d %q %v", tc.draft, at, query, ok, tc.at, tc.query, tc.ok) + } + } +} + +// Every word must match, in any order, and one that does not fails the whole +// needle. +func TestEveryWordOfTheNeedleMustMatch(t *testing.T) { + for _, tc := range []struct { + hay, needle string + ok bool + }{ + {"who is kim jong il", "who", true}, + {"who is kim jong il", "who is", true}, + {"who is kim jong il", "jong who", true}, + {"who is kim jong il", "who is kim jong il", true}, + {"who is kim jong il", "who was", false}, + {"internal/tui3/app.go", "tui3 app", true}, + {"internal/tui3/app.go", "app tui3", true}, + {"internal/tui3/app.go", "tui3 main", false}, + {"harbor harbor", "har bor", true}, + {"the parser parser the-parser", "parser about", false}, + {"anything", " ", true}, + // Only the word still being typed may match by its letters in order. + {"side chat side-chat", "sd chat", false}, + {"side chat side-chat", "side cht", true}, + {"side chat side-chat", "is a chat", false}, + } { + if _, ok := pathScore(tc.hay, strings.ToLower(tc.needle)); ok != tc.ok { + t.Errorf("pathScore(%q, %q) = %v, want %v", tc.hay, tc.needle, ok, tc.ok) + } + } +} + +// A sentence after a chosen mention does not keep the list up: several words +// that match nothing close it. One word that matches nothing still says so. +func TestTheListClosesOverASentenceAfterAMention(t *testing.T) { + a := mentionApp(t) + typeInto(t, a, "@chat:side ch") + if !a.comp.open || len(a.comp.chatHits) != 1 { + t.Fatalf("@chat:side ch kept the list open=%v with %d conversations", a.comp.open, len(a.comp.chatHits)) + } + drive(t, a, key("enter")) + if got := a.input.String(); got != "@side-chat" { + t.Fatalf("enter typed %q", got) + } + typeInto(t, a, " and then") + if a.comp.open { + t.Fatalf("the list is up over %q", a.input.String()) + } + a = mentionApp(t) + typeInto(t, a, "@chat:zzz") + if !a.comp.open { + t.Fatal("one word that matches nothing closed the list instead of saying so") + } + + // AND A TITLE THAT SHARES THE LETTERS. `is`, `a` and `chat` are all in + // `side chat` in order, which is what reopened the list and let enter put + // the mention back over the sentence. + a = mentionApp(t) + typeInto(t, a, "@chat:side") + drive(t, a, key("enter")) + typeInto(t, a, " is a chat") + if a.comp.open { + t.Fatalf("the list is up over %q", a.input.String()) + } + drive(t, a, key("enter")) + if got := a.input.String(); got == "@side-chat" { + t.Fatal("enter put the mention back in place of the sentence") + } + sent := false + for _, e := range a.entries { + if e.kind == entryUser && strings.Contains(e.text, "@side-chat is a chat") { + sent = true + } + } + if !sent { + t.Fatalf("enter did not send the whole line; box=%q", a.input.String()) + } +} + +// atBoxes is the one fixture every box is tested against: a team, an open +// conversation beside the one on screen, a recent one, and a file under the +// folder the list walks. +type atBox struct { + name string + // make raises the surface with the fixture in reach. + make func(t *testing.T) *app + // text is what the person sees of the list. + text func(a *app) string + // box is the words in the box the list is bound to. + box func(a *app) string +} + +func atFixture(t *testing.T, a *app, walk string) { + t.Helper() + emptyMachine(a) + a.wall.loaded = true + a.wall.teams = []team{{ + ID: "t1", Name: "harbor", Hue: 210, + Members: []teamMember{{Key: "/s/parser.jsonl", File: "/s/parser.jsonl", Handle: "parser", Word: "the parser"}}, + }} + a.comp.recentsHeld = true + a.comp.recents = []mentionChat{{key: "/s/side.jsonl", file: "/s/side.jsonl", title: "side chat", slug: "side-chat", note: "side chat"}} + a.stow(Conversation{ + Agent: &fakeAgent{model: "m"}, SessionFile: "/tmp/lab/kim.jsonl", Workspace: "/tmp/lab", Place: "lab", + }, &aside{since: a.now().Add(-12 * time.Minute), title: "who is kim jong il"}) + path := filepath.Join(walk, "internal", "tui3", "app.go") + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } +} + +var atBoxes = []atBox{ + { + name: "conversation", + make: func(t *testing.T) *app { + a := completionApp(t) + a.file, a.openingPrompt = "/tmp/lab/this-one.jsonl", "the current conversation" + atFixture(t, a, a.workspace) + a.width, a.height = 160, 40 + _ = a.tabsRow(a.width) + return a + }, + text: func(a *app) string { return plain(strings.Join(a.overlayRows(a.width, a.overlayHeight()), "\n")) }, + box: func(a *app) string { return a.input.String() }, + }, + { + name: "home", + make: func(t *testing.T) *app { + a, root := atHome(t) + atFixture(t, a, root) + return a + }, + text: homeText, + box: func(a *app) string { return a.home.box.String() }, + }, + { + name: "new chat page", + make: func(t *testing.T) *app { + lab := newStartLab(t) + a := lab.app() + a.workspace = t.TempDir() + atFixture(t, a, a.workspace) + openStart(t, a) + if !a.startingChat() { + t.Fatal("+ did not open the start page") + } + return a + }, + text: func(a *app) string { return plain(strings.Join(a.overlayRows(a.width, a.overlayHeight()), "\n")) }, + box: func(a *app) string { return a.input.String() }, + }, +} + +// comp is the list the box is bound to. +func (b atBox) comp(a *app) *completion { + if b.name == "home" { + return &a.home.comp + } + return &a.comp +} + +// TestTheAtListIsTheSameOnEveryBox: the same fixture, the same searches with +// spaces in them, the same answers — on a conversation's box, on home's, and +// on the new-chat page's. The owner asked for this to hold always. +func TestTheAtListIsTheSameOnEveryBox(t *testing.T) { + for _, b := range atBoxes { + t.Run(b.name, func(t *testing.T) { + a := b.make(t) + typeInto(t, a, "@") + c := b.comp(a) + if !c.open { + t.Fatal("the bare @ did not open the list") + } + text := b.text(a) + for _, want := range []string{"team chat file", "harbor", "who is kim jong il", "side chat", "app.go"} { + if !strings.Contains(text, want) { + t.Fatalf("the bare @ list is missing %q:\n%s", want, text) + } + } + + a = b.make(t) + typeInto(t, a, "@chat:who is") + c = b.comp(a) + if !c.open || len(c.chatHits) != 1 || c.chatHits[0].title != "who is kim jong il" { + t.Fatalf("@chat:who is: open=%v chats=%+v", c.open, c.chatHits) + } + if len(c.teamHits) != 0 || len(c.hits) != 0 { + t.Fatal("@chat: kept another section") + } + drive(t, a, key("enter")) + if got := b.box(a); got != "@who-is-kim-jong-il" { + t.Fatalf("choosing the conversation typed %q", got) + } + // THE SENTENCE AFTER THE MENTION IS NOT A SEARCH, even when every + // one of its words shares letters with the title: the list stays + // closed and the words stay in the box. + typeInto(t, a, " so it is") + if c.open { + t.Fatalf("the list reopened over %q", b.box(a)) + } + if got := b.box(a); got != "@who-is-kim-jong-il so it is" { + t.Fatalf("the sentence after the mention became %q", got) + } + + a = b.make(t) + typeInto(t, a, "@team:har bor") + c = b.comp(a) + if !c.open || len(c.teamHits) != 1 || len(c.chatHits) != 0 || len(c.hits) != 0 { + t.Fatalf("@team:har bor: open=%v teams=%d chats=%d files=%d", c.open, len(c.teamHits), len(c.chatHits), len(c.hits)) + } + drive(t, a, key("enter")) + if got := b.box(a); got != "●harbor" { + t.Fatalf("choosing the team typed %q", got) + } + + a = b.make(t) + typeInto(t, a, "@file:tui3 app") + c = b.comp(a) + if !c.open || len(c.hits) != 1 || !strings.HasSuffix(c.all[c.hits[0]], "app.go") || len(c.chatHits) != 0 { + t.Fatalf("@file:tui3 app: open=%v files=%d chats=%d", c.open, len(c.hits), len(c.chatHits)) + } + + a = b.make(t) + typeInto(t, a, "@chat:who was") + c = b.comp(a) + if c.open { + t.Fatal("several words that match nothing left the list up") + } + a = b.make(t) + typeInto(t, a, "@chat:zzz") + if text := b.text(a); !strings.Contains(text, "no conversation matches") { + t.Fatalf("one word that matches nothing does not say so:\n%s", text) + } + }) + } +} diff --git a/internal/tui3/files.go b/internal/tui3/files.go index a49cb9504..2b363f8ae 100644 --- a/internal/tui3/files.go +++ b/internal/tui3/files.go @@ -6,6 +6,7 @@ import ( "path/filepath" "sort" "strings" + "unicode" tea "charm.land/bubbletea/v2" @@ -77,6 +78,10 @@ var skipDirs = map[string]bool{ // keeps its walk across closes: the second @ of a session opens instantly. type completion struct { open bool + // tokenAt is the current @ token's start plus one, or zero without a token. + // A negative start means Escape dismissed its opening; automatic display + // closes keep it positive, so edits within it share one recent read. + tokenAt int // at is the rune index of the '@' in the draft, and query what follows it. at int query string @@ -86,10 +91,9 @@ type completion struct { // and an image is written into the line like any other file, because in // "/image shot.png" the path is what the command takes. arg bool - // done is the query this completion just INSERTED at at. It is what keeps - // the list from reopening on top of its own answer: the caret ends up - // inside a perfectly good @token, and a list that reappeared over it would - // make enter a key that never finishes. + // done is the query this completion inserted or the person dismissed at at. + // It keeps the list from reopening over its own answer or over a separator + // typed after Escape, so the person can finish a mention and write on. done string all []string @@ -120,8 +124,9 @@ type completion struct { older int // teams and chats are the catalogs this list ranks, copied from memory on - // the update loop (mention.go). recents is the recent-conversation snapshot - // loaded once, off the loop, because that list can touch the disk. + // the update loop (mention.go). recents is the recent-conversation snapshot, + // read off the loop because that list can touch the disk. Each opening asks + // for fresh rows so a conversation started since is on it. teams []mentionTeam chats []mentionChat recents []mentionChat @@ -131,8 +136,21 @@ type completion struct { // scope is which section a prefix narrowed to: "team", "chat", "file", or // "" for every section at once. scope string - // recentsHeld says a read of the recent list is already in flight. + // recentsHeld says a read of the recent list is in flight. It is set when + // the read is started and cleared when its rows land, and a test that + // hands the list its own recents sets it to keep the door shut. recentsHeld bool + // recentsLoaded distinguishes an unread catalog from a read that found nothing. + recentsLoaded bool + // recentsPending marks a scheduled read, so a supplied catalog whose door + // is held shut remains ready without being mistaken for an unanswered read. + recentsPending bool + // recentsAgain records openings since the in-flight read began. They share + // one follow-up, because an older read cannot settle their search as fresh. + recentsAgain bool + // recentsRead identifies the in-flight read, so a duplicate older answer + // cannot settle its follow-up. Only the app's shared catalog uses it. + recentsRead uint64 // lines is what the overlay DRAWS, section rules included, and sel is the // line each selectable row sits on, in cursor order. The split is what lets @@ -143,6 +161,9 @@ type completion struct { cursor int top int + // chosen is the identity the person selected with arrows, or empty before + // a choice. Query changes clear it; ranking follows it while still offered. + chosen string } // compLine is one drawn row: a section rule, the prefix words, a team, a @@ -196,6 +217,12 @@ func (c *completion) sync(e *editor) { } at, query, ok := atToken(e.value, e.cursor) if !ok || len([]rune(query)) < completeMin { + c.open, c.tokenAt, c.done = false, 0, "" + return + } + if c.tokenAt == -(at + 1) { + // Home builds before asking beginToken about the edit. A dismissed list + // must wait for that question before adopting the next letter's query. c.open = false return } @@ -203,23 +230,85 @@ func (c *completion) sync(e *editor) { // takes the "@" out. Matching it here closed a list on the first "@" of // a draft, because that token sits at rune 0 with an empty query and the // zero at is 0 too. - if c.done != "" && at == c.at && query == c.done { - c.open = false - return + // THE SENTENCE AFTER A CHOSEN MENTION IS NOT A SEARCH. A separator + // means the person is writing on, including punctuation typed before a space. + if c.done != "" && at == c.at && strings.HasPrefix(query, c.done) { + tail := []rune(strings.TrimPrefix(query, c.done)) + if len(tail) == 0 || !mentionContinuation(tail[0]) { + c.open = false + return + } } c.narrow(at, query, false) + // A MULTI-WORD SEARCH THAT MATCHES NOTHING AFTER ITS CATALOGS ARRIVE + // closes rather than saying `no matches` under it: the words after a + // chosen `@side-chat` are the message, not a search for it, and a list + // that stayed up over them would be a list over every sentence that + // mentions somebody. One word that matches nothing still says so, exactly + // as it did before spaces were allowed. + if strings.Contains(strings.TrimSpace(query), " ") && !c.anyHits() && c.catalogsReady() { + c.open = false + } +} + +// beginToken reports a new @ token or an opening returning after Escape. +// Every box calls it after edits, including the edit that removes the token. +func (c *completion) beginToken(e *editor) bool { + at, query, ok := atToken(e.value, e.cursor) + if strings.HasPrefix(strings.TrimSpace(e.String()), "!") { + ok = false + } + if _, _, arg := argToken(e.value, e.cursor); arg { + ok = false + } + token := 0 + if ok { + token = at + 1 + } + previous := c.tokenAt + if token == 0 { + c.tokenAt = 0 + // The @ token's end must not erase a command argument's insertion stamp. + if !c.arg { + c.done = "" + } + return false + } + if token == previous || previous == -token && query == c.query { + return false + } + // A completed mention or a separator after Escape keeps the list closed. + if (previous == 0 || previous == -token) && (c.done != "" || previous == -token) && at == c.at && strings.HasPrefix(query, c.done) { + tail := []rune(strings.TrimPrefix(query, c.done)) + if len(tail) == 0 || !mentionContinuation(tail[0]) { + return false + } + } + c.tokenAt = token + c.done = "" + return true +} + +// refresh reconsiders a search after its catalogs arrive. An argument opened +// by tab must stay open even when its empty query equals the zero insertion stamp. +func (c *completion) refresh(e *editor) { + if c.open && !c.arg { + c.sync(e) + } else { + c.rank() + } } // narrow is the half sync and [completion.openArg] share: point the list at a // token, re-rank it, and keep the cursor only if it is still walking the same // list. func (c *completion) narrow(at int, query string, arg bool) { - was := c.open && c.at == at && c.arg == arg + was := c.open && c.at == at && c.arg == arg && c.query == query c.open, c.arg, c.at, c.query = true, arg, at, query - c.rank() if !was { - c.cursor, c.top = 0, 0 + c.cursor, c.top, c.chosen = 0, 0, "" } + c.rank() } // openArg is tab: open the list over a command's path argument even when @@ -236,6 +325,16 @@ func (c *completion) openArg(e *editor) bool { func (c *completion) close() { c.open = false } +// dismiss ends the person's opening while leaving its token in the box. The +// next query edit can open it anew; ordinary display closes keep the opening. +func (c *completion) dismiss() { + c.close() + if !c.arg { + c.done = c.query + c.tokenAt = -(c.at + 1) + } +} + // argPrefixes are the commands that take a PATH, spelled as they are typed. // // There was one of them, and the line here said that one is not a list. There @@ -267,19 +366,101 @@ func argToken(value []rune, cursor int) (int, string, bool) { return 0, "", false } -// atToken finds the @-word the caret is standing in: the run back to a space, -// a newline or the start of the draft, which must begin with '@'. An @ in the -// middle of a word (an email address, a Go doc link) is not one — the run has -// to start with it. +// atToken finds the @-word the caret is standing in. A bare token ends at +// its first space; only a section prefix admits spaces, and a newline ends both. +// An @ in the middle of a word, such as an email address or a Go doc link, +// never opens the list. func atToken(value []rune, cursor int) (int, string, bool) { - start := cursor - for start > 0 && value[start-1] != ' ' && value[start-1] != '\n' { - start-- + spaces := 0 + for start := cursor - 1; start >= 0; start-- { + switch r := value[start]; { + case r == '\n': + return 0, "", false + case r == ' ': + // A PREFIXED SEARCH MAY HAVE SPACES IN IT, up to + // [atTokenSpaces]: `@chat:who is` finds `who is kim jong il`, and + // `@file:internal tui3` finds internal/tui3. Past that many it is a + // sentence, not a search. + spaces++ + if spaces > atTokenSpaces { + return 0, "", false + } + case r == '@' && (start == 0 || value[start-1] == ' ' || value[start-1] == '\n'): + // The nearest `@` that BEGINS a word: one in the middle of an email + // address or a Go doc link never opens the list. + query := string(value[start+1 : cursor]) + if scope, _ := mentionScope(query); spaces > 0 && scope == "" { + return 0, "", false + } + return start, query, true + } + } + return 0, "", false +} + +// atTokenSpaces is how many spaces a prefixed `@` search may hold before its words +// stop being a search. Three is a title's worth — `who is kim jong` — and it +// is also what bounds the walk back from the caret through a long message. +const atTokenSpaces = 3 + +// anyHits reports whether the last ranking kept a single row of any section. +func (c *completion) anyHits() bool { + return len(c.hits) > 0 || len(c.teamHits) > 0 || len(c.chatHits) > 0 || len(c.taskHits) > 0 +} + +// mentionContinuation is the spelling a handle or path may extend with. A dot +// after a complete choice is punctuation; existing dots inside the choice stay text. +func mentionContinuation(r rune) bool { + return unicode.IsLetter(r) || unicode.IsDigit(r) || strings.ContainsRune("_-/\\", r) +} + +// catalogsReady keeps a first pasted search open until the catalog it needs +// has answered. An empty read is an answer; an unread catalog is not. +func (c *completion) catalogsReady() bool { + switch c.scope { + case scopeTeam: + return true + case scopeChat: + return c.recentsLoaded + case scopeFile: + return c.loaded + default: + return c.loaded + } +} + +// selectionKey names a row independently of its position, so data arriving +// cannot change what enter chooses while that row is still offered. +func (c *completion) selectionKey(at int) string { + if at < 0 || at >= len(c.lines) { + return "" } - if start >= cursor || value[start] != '@' { - return 0, "", false + line := c.lines[at] + switch { + case line.team >= 0 && line.team < len(c.teamHits): + return "team:" + c.teamHits[line.team].id + case line.chat >= 0 && line.chat < len(c.chatHits): + return "chat:" + c.chatHits[line.chat].key + case line.file >= 0 && line.file < len(c.all): + return "file:" + c.all[line.file] + case line.task >= 0 && line.task < len(c.taskHits): + row := c.taskHits[line.task] + return "task:" + row.SessionID + ":" + row.ID + } + return "" +} + +// restoreSelection follows the chosen row through a fresh ranking. A row +// removed by the new data, or never chosen, gives the best match the cursor. +func (c *completion) restoreSelection(chosen string) { + c.cursor, c.top, c.chosen = 0, 0, "" + for cursor, at := range c.sel { + if chosen != "" && c.selectionKey(at) == chosen { + c.cursor, c.chosen = cursor, chosen + break + } } - return start, string(value[start+1 : cursor]), true + c.follow(c.rowsWanted()) } // rank scores every path, team, conversation and task against the query, keeps @@ -315,8 +496,7 @@ func (c *completion) rank() { c.rankTasks() } c.layout() - c.cursor = moveCursor(c.cursor, 0, len(c.sel)) - c.follow(c.rowsWanted()) + c.restoreSelection(c.chosen) } // layout turns the two ranked lists into the rows the overlay draws: the task @@ -348,9 +528,12 @@ func (c *completion) layout() { c.lines = append(c.lines, line) } selectable := len(c.teamHits) > 0 || len(c.chatHits) > 0 || len(c.taskHits) > 0 || len(c.hits) > 0 - if !c.arg && !selectable && c.loaded { + if !c.arg && !selectable { line := deadLine() - line.header = c.emptyWord() + line.header = homeLookingWord + if c.catalogsReady() { + line.header = c.emptyWord() + } c.lines = append(c.lines, line) } for at, line := range c.lines { @@ -388,9 +571,37 @@ const ( // ranks a hair below the path so that a query which is genuinely a path prefix // leads. func pathScore(path, needle string) (int, bool) { - if needle == "" { + words := strings.Fields(needle) + switch len(words) { + case 0: return len(path), true + case 1: + return wordScore(path, words[0], true) + } + // EVERY WORD MUST MATCH, each on its own terms and in any order, and the + // scores add: `who is` finds `who is kim jong il` by substring twice, and + // `tui3 app` finds internal/tui3/app.go with the words the other way round + // from the path. One word that matches nothing fails the whole needle. + // + // ONLY THE LAST WORD MAY MATCH BY ITS LETTERS IN ORDER — it is the one still + // being typed. Every word before it is finished, and a finished word + // matches whole, as a prefix or a substring: a needle whose every word may + // scatter its letters through a title matches nearly any title, which is + // how `is a chat` matched `side chat` and kept the list up over a sentence. + total := 0 + for i, word := range words { + score, ok := wordScore(path, word, i == len(words)-1) + if !ok { + return 0, false + } + total += score } + return total, true +} + +// wordScore is [pathScore] for one word: prefix, then substring, then — when +// loose — the letters in order. +func wordScore(path, needle string, loose bool) (int, bool) { lower := strings.ToLower(path) base := lower if cut := strings.LastIndexByte(lower, '/'); cut >= 0 { @@ -405,6 +616,9 @@ func pathScore(path, needle string) (int, bool) { if at := strings.Index(lower, needle); at >= 0 { return tierSubstring + at<<8 + len(path), true } + if !loose { + return 0, false + } if span, ok := subsequence(lower, needle); ok { return tierSubsequence + span<<8 + len(path), true } @@ -437,6 +651,7 @@ func subsequence(text, needle string) (int, bool) { func (c *completion) move(delta int) { c.cursor = moveCursor(c.cursor, delta, len(c.sel)) + c.chosen = c.selectionKey(c.selLine()) c.follow(c.rowsWanted()) } @@ -544,22 +759,25 @@ func isFolderPath(path string) bool { return strings.HasSuffix(path, "/") } // the line's height at [tierPhone], so [completion.height] and // [completion.rows] ask it rather than each deciding for themselves. func (c *completion) lineNote(at int) string { + if at < 0 || at >= len(c.lines) { + return "" + } line := c.lines[at] switch { case line.header != "" || line.filters: return "" - case line.team >= 0: + case line.team >= 0 && line.team < len(c.teamHits): return mentionCount(c.teamHits[line.team]) - case line.chat >= 0: + case line.chat >= 0 && line.chat < len(c.chatHits): return c.chatHits[line.chat].note - case line.task >= 0: + case line.task >= 0 && line.task < len(c.taskHits): return taskNoteWord(c.taskHits[line.task]) - case isFolderPath(c.all[line.file]): + case line.file >= 0 && line.file < len(c.all) && isFolderPath(c.all[line.file]): // A FOLDER IS A FOLDER ON BOTH LISTS, `@` and a command's argument // alike — the tag says what the row IS, and that does not change with // the door it was opened from the way the picture's tag does. return folderTag - case !c.arg && isImagePath(c.all[line.file]): + case !c.arg && line.file >= 0 && line.file < len(c.all) && isImagePath(c.all[line.file]): return imageTag default: return "" @@ -609,6 +827,8 @@ type filesLoadedMsg struct { paths []string // home says the walk was home's list's (homeat.go) and not the box's. home bool + // root identifies a home walk, so an older target cannot replace a newer catalog. + root string } // loadFiles walks the workspace off the loop. It runs ONCE per surface: the diff --git a/internal/tui3/home.go b/internal/tui3/home.go index e14f8517e..ccc0957fd 100644 --- a/internal/tui3/home.go +++ b/internal/tui3/home.go @@ -1458,6 +1458,9 @@ func (a *app) refreshHome() { func (h *homeView) build() { if len(h.box.value) == 0 { h.projectPaste = homeProjectPaste{} + // An empty box draws the grid without visiting completionLines, so it + // must close the list before that grid inherits the previous choice. + h.comp.sync(&h.box) } previous := h.focused() // AND THE ITEM UNDER THE CURSOR IS FOLLOWED THE SAME WAY. A band re-sorts @@ -1516,7 +1519,7 @@ func (h *homeView) build() { 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.cursor = h.clamp(h.completionCursor()) h.picked = len(h.lines) > 0 return } @@ -2410,6 +2413,20 @@ func (h *homeView) move(delta int) { if delta == 0 || len(h.lines) == 0 { return } + // Home and the list share one choice, so an arrival can follow the row + // an arrow selected rather than restoring a second, stale cursor. + if h.comp.open { + for cursor, at := range h.comp.sel { + if at == h.cursor { + h.comp.cursor = cursor + break + } + } + h.comp.move(delta) + h.cursor = h.clamp(h.completionCursor()) + h.picked = len(h.lines) > 0 + return + } // ON THE GRID THE WALK STAYS IN ITS COLUMN (homegrid.go's [homeView.gridMove]): // the lines are laid out column by column, and a walk off the foot of one // column into the top of the next would be the cursor jumping across the @@ -3205,8 +3222,9 @@ func (a *app) homeEnter() tea.Cmd { // (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) + // ENTER PUTS THE TEAM, THE CONVERSATION OR THE PATH IN, or a picture on + // the tray (homeat.go). + return a.homeComplete(line) case homeAskHere: // The same sentence, asked rather than opened (homeexchange.go). return a.askHere(strings.TrimSpace(h.box.String())) @@ -4664,7 +4682,7 @@ func (a *app) homeList(width, room int, pal palette) []homeDrawn { case h.comp.open && !h.comp.loaded: word = homeLookingWord case h.comp.open: - word = homeNoFileWord + word = h.comp.emptyWord() case h.searching(): word = homeNoMatchWord case !h.known: @@ -4798,8 +4816,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). + case homeCompletion, homeCompletionRule: + // A TEAM, A CONVERSATION OR A PATH, OFFERED BECAUSE THE WORDS AFTER `@` + // MATCH IT, and the rules of that list (homeat.go). return a.homeCompletionRow(line, at, width, pal) case homeCommand: // A COMMAND, OFFERED BECAUSE THE WORDS MATCH ITS NAME OR AN ALIAS diff --git a/internal/tui3/homeat.go b/internal/tui3/homeat.go index e94276c7b..418913803 100644 --- a/internal/tui3/homeat.go +++ b/internal/tui3/homeat.go @@ -2,6 +2,7 @@ package tui3 import ( "path/filepath" + "strings" tea "charm.land/bubbletea/v2" ) @@ -22,29 +23,52 @@ import ( // pointer is minted when a conversation sends (taskmention.go), and home has no // conversation to mint it in yet. // +// AND IT OFFERS TEAMS AND CONVERSATIONS, with the same prefix words on its first +// row and the same `@team:`, `@chat:` and `@file:` prefixes a conversation's +// box takes (mention.go). Until 2026-10-01 home's list was files alone, and a +// person who had learnt `@chat:` in a conversation found it answered `no file +// matches` on home: one search behaves one way on every box. The catalogs are +// the same memory the conversation's list copies ([app.fillHomeMentions]), the +// recent list is refreshed on openings exactly as there, and choosing a +// team or a conversation types exactly what it types there ([completeTeamIn], +// [completeChatIn]). The one difference is which conversation is left off: a +// conversation's list leaves off the one being typed in, and home's leaves off +// none, because its sentence opens a new one. +// // 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. +// homeCompletion is one row of that list a person can stand on: a team, a +// conversation or a path. It is numbered outside the [homeRowKind] iota block +// for [homeCommand]'s reason. const homeCompletion homeRowKind = 251 +// homeCompletionRule is a line of that list that NAMES rows rather than being +// one: the prefix words on its first row, a section's heading, or the one dim +// sentence saying nothing matched. It is never a cursor stop and a press on +// it does nothing, exactly as a project's heading on this column. +const homeCompletionRule homeRowKind = 252 + // 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" -) +// homeLookingWord is the list's one empty state of its own, in the conversation +// list's own words ([completion.rows]): the walk still running. A query nothing +// matches is said by the list itself, in the section's words — `no conversation +// matches` under `@chat:` ([completion.emptyWord]). +const homeLookingWord = "looking…" -// 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 +// completionLines syncs the list against the box and hands back its lines, 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). +// +// EVERY LINE OF THE LIST IS A LINE OF THE COLUMN, rules included, and the two +// index each other one to one: [homeLine.comp] is the line's place in the +// list, and the list's own cursor ([completion.selLine]) is where home's +// cursor opens ([homeView.build]). func (h *homeView) completionLines() []homeLine { h.comp.sync(&h.box) if !h.comp.open { @@ -52,81 +76,221 @@ func (h *homeView) completionLines() []homeLine { } lines := make([]homeLine, 0, len(h.comp.lines)) for i, line := range h.comp.lines { - if line.header != "" || line.file < 0 { - continue + kind := homeCompletion + if line.filters || line.header != "" { + kind = homeCompletionRule } - lines = append(lines, homeLine{kind: homeCompletion, comp: i}) + lines = append(lines, homeLine{kind: kind, 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. +// completionCursor is the line of the column home's cursor opens on when the +// list is up: the list's own choice, which rank() moves with the query, and +// the first row when the list has none. +func (h *homeView) completionCursor() int { + if at := h.comp.selLine(); at >= 0 { + return at + } + return 0 +} + +// homeCompletionRow paints one line of the list: a rule as the list draws its +// own — the prefix words with the one in force accented, a dim heading, the +// dim empty sentence — and a row as its label and its margin: a team's dot and +// name with how many conversations it holds, a conversation's handle with its +// title, a path with `folder` or `img`. func (a *app) homeCompletionRow(line homeLine, at, width int, pal palette) string { h := &a.home - path, note, ok := h.completionWords(line) + c := &h.comp + if line.comp < 0 || line.comp >= len(c.lines) { + return "" + } + if line.kind == homeCompletionRule { + cl := c.lines[line.comp] + if cl.filters { + return mentionHeadLine(pal, c.scope, "", width) + } + return pal.dim(" " + fit(cl.header, width-2)) + } + label, note, ok := h.completionWords(line, pal) if !ok { return "" } - return overlayRow(path, note, at == h.cursor, false, at == h.hover && at == h.cursor, width, pal) + return overlayRow(label, 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) { +// completionWords is what a row of the list says — its label and its dim +// margin — and false for a line that no longer points at a row. +func (h *homeView) completionWords(line homeLine, pal palette) (label, note string, ok bool) { c := &h.comp - if line.comp < 0 || line.comp >= len(c.lines) || c.lines[line.comp].file < 0 { + if line.comp < 0 || line.comp >= len(c.lines) { return "", "", false } - return c.all[c.lines[line.comp].file], c.lineNote(line.comp), true + cl := c.lines[line.comp] + switch { + case cl.team >= 0 && cl.team < len(c.teamHits): + return mentionTeamLabel(c.teamHits[cl.team], pal), c.lineNote(line.comp), true + case cl.chat >= 0 && cl.chat < len(c.chatHits): + return mentionChatLabel(c.chatHits[cl.chat]), c.lineNote(line.comp), true + case cl.file >= 0 && cl.file < len(c.all): + return c.all[cl.file], c.lineNote(line.comp), true + } + return "", "", false +} + +// completionPath is the path a row offers, and false on a row that is not one. +func (h *homeView) completionPath(line homeLine) (string, bool) { + c := &h.comp + if line.comp < 0 || line.comp >= len(c.lines) || c.lines[line.comp].file < 0 { + return "", false + } + at := c.lines[line.comp].file + if at >= len(c.all) { + return "", false + } + return c.all[at], true +} + +// fillHomeMentions copies the in-memory catalogs onto home's list, the way +// [app.fillMentions] copies them onto the conversation's. It runs before a key +// or paste reaches home's box and never from a frame, and it leaves no +// conversation off (the file's own note). +func (a *app) fillHomeMentions() { + a.home.comp.teams = a.mentionTeams() + a.home.comp.chats = a.mentionChatsExcept("") + a.home.comp.recentsLoaded = a.mentionRecentsReady(true) +} + +// homeCompletionRoot uses the explicit pin, otherwise the conversation's file +// root. Hosted lists offer this machine's files while sentences open on the far one. +func (a *app) homeCompletionRoot() string { + if pinned := strings.TrimSpace(a.target.where); pinned != "" { + return pinned + } + return a.pathRoot() } -// 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 { +// prepareHomeCompletion supplies catalogs before an edit can rank them. Keys +// outside a search leave the completed file walk and the mention catalogs alone. +func (a *app) prepareHomeCompletion(text string) { h := &a.home + h.comp.recentsLoaded = a.mentionRecentsReady(true) if !h.comp.open { - return nil + if text == "" { + return + } + if !strings.Contains(text, "@") { + if _, _, ok := atToken(h.box.value, h.box.cursor); !ok { + return + } + } + // Only an edit that can open a search needs catalogs in advance. Arrows, + // prose and punctuation after a chosen mention preserve the closed cache. + value := append(append([]rune(nil), h.box.value[:h.box.cursor]...), []rune(text)...) + at, query, ok := atToken(value, len(value)) + if !ok { + return + } + if (h.comp.done != "" || h.comp.tokenAt == -(at+1)) && at == h.comp.at && strings.HasPrefix(query, h.comp.done) { + tail := []rune(strings.TrimPrefix(query, h.comp.done)) + if len(tail) == 0 || !mentionContinuation(tail[0]) { + return + } + } } - root := a.targetWhere() - if root == "" { - root = a.pathRoot() + a.fillHomeMentions() + a.alignHomeFiles(a.homeCompletionRoot()) +} + +// syncHomeCompletion starts the same reads for every opening. Catalogs are +// supplied after nonprinting edits too, before the next frame can draw them. +func (a *app) syncHomeCompletion(_ bool) tea.Cmd { + h := &a.home + root := a.homeCompletionRoot() + var recents tea.Cmd + opening := h.comp.beginToken(&h.box) + if opening { + a.noticeEvent(eventAtOpened) + recents = a.loadMentionRecents() + a.fillHomeMentions() + a.alignHomeFiles(root) } - if root == "" { - return nil + h.comp.sync(&h.box) + if opening || h.comp.open { + h.build() } - if h.walked != root { - h.comp.all, h.comp.loaded, h.comp.loading = nil, false, false - h.walked = root + return tea.Batch(a.loadHomeFiles(root), recents) +} + +// loadHomeFiles walks the completion folder once, off the loop. Catalogs and +// their ranked rows change together, so no frame can paint an old file index. +func (a *app) loadHomeFiles(root string) tea.Cmd { + h := &a.home + if !h.comp.open || root == "" { + return nil } + a.alignHomeFiles(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. + // Home offers no task pointers, so there is no task read to wait for. h.comp.tasksLoaded, h.comp.loading = true, true - return func() tea.Msg { return filesLoadedMsg{paths: walkFiles(root, walkCap), home: true} } + return func() tea.Msg { return filesLoadedMsg{paths: walkFiles(root, walkCap), home: true, root: root} } +} + +// alignHomeFiles replaces paths and ranked rows together. An opening must not +// dismiss a spaced file search against a completed walk of another folder. +func (a *app) alignHomeFiles(root string) { + h := &a.home + if h.walked == root { + return + } + h.comp.all, h.comp.loaded, h.comp.loading = nil, false, false + h.walked = root + h.comp.rank() + if h.comp.open { + h.build() + } } // homeFilesLoaded takes the walk back onto home's list and rebuilds the rows // under the cursor. -func (a *app) homeFilesLoaded(paths []string) { +func (a *app) homeFilesLoaded(paths []string, root string) { h := &a.home + if root != "" && root != h.walked { + return + } 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 { +// homeComplete is enter on a row of the list, and it is [app.completeFile], +// [app.completeTeam] and [app.completeChat] said for home's box: a team's mark +// or a conversation's handle goes into the sentence exactly as it does there, +// a path goes in after the `@`, or a picture comes out of the sentence and +// onto the tray. +func (a *app) homeComplete(line homeLine) tea.Cmd { h := &a.home c := &h.comp - path, _, ok := h.completionWords(line) + if line.comp >= 0 && line.comp < len(c.lines) { + cl := c.lines[line.comp] + switch { + case cl.team >= 0 && cl.team < len(c.teamHits): + completeTeamIn(&h.box, c, c.teamHits[cl.team]) + h.build() + a.touch() + return nil + case cl.chat >= 0 && cl.chat < len(c.chatHits): + completeChatIn(&h.box, c, c.chatHits[cl.chat]) + h.build() + a.touch() + return nil + } + } + path, ok := h.completionPath(line) if !ok { c.close() h.build() @@ -166,8 +330,9 @@ func (a *app) homeCompleteFile(line homeLine) tea.Cmd { // 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. +// command list). The dismissal ends the opening, so its next letter reads anew. +// [completion.done] also keeps a separator after this query from reopening it. func (h *homeView) dismissCompletion() { h.comp.done = h.comp.query - h.comp.close() + h.comp.dismiss() } diff --git a/internal/tui3/homeat_test.go b/internal/tui3/homeat_test.go index 4c5ac779d..6622c3c3a 100644 --- a/internal/tui3/homeat_test.go +++ b/internal/tui3/homeat_test.go @@ -169,3 +169,113 @@ func TestThereIsNoImageCommand(t *testing.T) { t.Fatalf("/image was not answered as an unknown word:\n%s", body) } } + +// atHomeWithMentions is [atHome] with a team and a recent conversation in +// reach, the way [mentionApp] sets a conversation's box up. +func atHomeWithMentions(t *testing.T) *app { + t.Helper() + a, _ := atHome(t) + a.wall.loaded = true + a.wall.teams = []team{{ + ID: "t1", Name: "harbor", Hue: 210, + Members: []teamMember{ + {Key: "/s/parser.jsonl", File: "/s/parser.jsonl", Handle: "parser", Word: "the parser"}, + }, + }} + a.comp.recentsHeld = true + a.comp.recents = []mentionChat{{ + key: "/s/side.jsonl", file: "/s/side.jsonl", + title: "side chat", slug: "side-chat", note: "side chat", + }} + return a +} + +// Home's `@` list is the conversation's: the prefix words on its first row, +// teams and conversations above the files, and the same three prefixes. +func TestAtOnHomeOffersTeamsAndConversationsWithThePrefixes(t *testing.T) { + a := atHomeWithMentions(t) + drive(t, a, key("@")) + h := &a.home + if !h.comp.open { + t.Fatal("the bare @ did not open the list on home") + } + text := homeText(a) + for _, want := range []string{"team chat file", "teams", "harbor", "conversations", "side chat", "notes.md"} { + if !strings.Contains(text, want) { + t.Fatalf("home's list is missing %q:\n%s", want, text) + } + } + line, ok := h.focusedLine() + if !ok || line.kind != homeCompletion { + t.Fatalf("the cursor is not on a row of the list: %+v", line) + } + if label, _, _ := h.completionWords(line, a.pal); !strings.Contains(label, "harbor") { + t.Fatalf("the cursor opened on %q, and the team is the first row", label) + } + + a = atHomeWithMentions(t) + drive(t, a, key("@"), key("c"), key("h"), key("a"), key("t"), key(":"), key("s"), key("i")) + h = &a.home + if len(h.comp.chatHits) != 1 || len(h.comp.teamHits) != 0 || len(h.comp.hits) != 0 { + t.Fatalf("@chat:si kept teams=%d chats=%d files=%d", len(h.comp.teamHits), len(h.comp.chatHits), len(h.comp.hits)) + } + drive(t, a, key("enter")) + if got := h.box.String(); got != "@side-chat" { + t.Fatalf("choosing the conversation on home typed %q", got) + } + if h.comp.open || !a.at(pageHome) { + t.Fatal("choosing a conversation left the list open or left home") + } + + a = atHomeWithMentions(t) + drive(t, a, key("@"), key("t"), key("e"), key("a"), key("m"), key(":"), key("h")) + h = &a.home + drive(t, a, key("enter")) + if got := h.box.String(); got != "●harbor" { + t.Fatalf("choosing the team on home typed %q", got) + } + + a = atHomeWithMentions(t) + drive(t, a, key("@"), key("c"), key("h"), key("a"), key("t"), key(":"), key("z"), key("z")) + if text := homeText(a); !strings.Contains(text, "no conversation matches") { + t.Fatalf("a chat prefix nothing matches does not say so on home:\n%s", text) + } + if !strings.Contains(a.homeHint(), "enter put it in") { + t.Fatalf("the foot is %q", a.homeHint()) + } +} + +// Home reads the recent conversations on every opening of its list, as a +// conversation's box does, and not on the letters typed after the `@`. +func TestAtOnHomeReadsRecentConversationsOnEachOpening(t *testing.T) { + a := atHomeWithMentions(t) + a.comp.recentsHeld, a.comp.recents = false, nil + reads := 0 + rows := []Session{{Title: "openrouter price scrape", File: "/s/price.jsonl"}} + a.recentSessions = func() []Session { + reads++ + return append([]Session(nil), rows...) + } + drive(t, a, key("@"), key("c"), key("h"), key("a"), key("t"), key(":")) + h := &a.home + if reads != 1 { + t.Fatalf("the first opening read the recent list %d times", reads) + } + if len(h.comp.chatHits) != 1 || h.comp.chatHits[0].title != "openrouter price scrape" { + t.Fatalf("home's list holds %+v", h.comp.chatHits) + } + drive(t, a, key("esc")) + rows = append(rows, Session{Title: "Cloudflare worker deploy", File: "/s/cloudflare.jsonl"}) + drive(t, a, key("c")) + if reads != 2 { + t.Fatalf("the second opening left the recent list at %d reads", reads) + } + // The letter after it narrows what the opening read, and reads nothing. + drive(t, a, key("l")) + if reads != 2 { + t.Fatalf("typing into the open list read the recent list again: %d reads", reads) + } + if len(h.comp.chatHits) != 1 || h.comp.chatHits[0].title != "Cloudflare worker deploy" { + t.Fatalf("a conversation started after the first opening is not on home's list: %+v", h.comp.chatHits) + } +} diff --git a/internal/tui3/homephone.go b/internal/tui3/homephone.go index deb6eefe5..bb8a7fbb1 100644 --- a/internal/tui3/homephone.go +++ b/internal/tui3/homephone.go @@ -491,6 +491,7 @@ func (a *app) homePhoneFrame(width, height int) ([]string, []int, int, int) { // Use the same multiline layout as the wide frame, including blank lines // and the caret's actual position rather than the end of the draft. draft, draftX, draftY := draftBlockWithTags(&a.home.box, pal, width-2, homeDraftRows, placeRestWord, "", nil, pal.ink) + draft = a.paintDraftMentions(draft) foot := 2 + len(draft) // The rule, the draft rows, and the bar. room := height - len(lines) - foot if room < 1 { @@ -643,6 +644,9 @@ func (a *app) homePhoneRow(line homeLine, at, width int, pal palette) []string { // a wide frame (home.go's [app.homeLine]): a tap opens any project's // conversation now, so there is no door to mark as shut. return []string{" " + pal.dim(fit(line.project, width-1))} + case homeCompletionRule: + // THE `@` LIST'S OWN RULES, drawn as the wide frame draws them (homeat.go). + return []string{a.homeCompletionRow(line, at, width, pal)} } label, note, tint := a.homePhoneWords(line, pal) // THE CONVERSATION THIS TERMINAL IS IN KEEPS ITS MARK ON A PHONE TOO. It is @@ -689,8 +693,8 @@ func (a *app) homePhoneWords(line homeLine, pal palette) (string, string, noteIn case homeCommand: return line.cmd.typed(), line.cmd.note(a.chords), nil case homeCompletion: - path, note, _ := h.completionWords(line) - return path, note, nil + label, note, _ := h.completionWords(line, pal) + return label, 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/mention.go b/internal/tui3/mention.go index bfb3fa524..51e885b7a 100644 --- a/internal/tui3/mention.go +++ b/internal/tui3/mention.go @@ -1,10 +1,10 @@ package tui3 import ( + "path/filepath" "strings" tea "charm.land/bubbletea/v2" - "github.com/charmbracelet/x/ansi" "github.com/Agent-Field/codeaf/internal/session" ) @@ -17,9 +17,12 @@ import ( // // THE CATALOG IS MEMORY. Teams are the ones this window already loaded // ([app.wall.teams]). Conversations are the ones open in this window, then the -// recent list the door already handed the surface. Nothing here opens a file -// on a frame. The recent list is read once, inside a command, because that -// read can touch the disk. +// recent list the door hands the surface. Nothing here opens a file on a +// frame. The recent list is read inside a command, because that read can +// touch the disk, and it is read again EVERY TIME THE LIST OPENS: a snapshot +// taken once per process left a conversation started in another window, or +// named after this window's first "@", off the list for as long as the window +// lived, while its name sat in plain sight on a tab strip. // // A PREFIX NARROWS THE LIST TO ONE SECTION. "@team:", "@chat:" and "@file:" // are the three, and the same three words sit on the list's first row, each @@ -36,9 +39,16 @@ const ( scopeTeam = "team" scopeChat = "chat" scopeFile = "file" - // mentionRows is how many teams, and how many conversations, one list draws. - // It is the file list's own screenful. - mentionRows = 8 + // mentionRows is how many teams, and how many conversations, the MIXED list + // keeps — the bare "@", where every section shares one screenful and the + // files and tasks under them still have to be reachable. It is the file + // list's own screenful. + mentionRows = completeRows + // mentionRowsScoped is the cap once a prefix has narrowed the list to one + // section. The person asked for conversations and nothing else, so the + // list holds as many as the file list does and scrolls, instead of showing + // eight of thirty open tabs and no sign of the rest. + mentionRowsScoped = completeRows * 4 // mentionRecentCap is how many recent conversations the snapshot keeps. mentionRecentCap = 24 ) @@ -93,7 +103,7 @@ func (c *completion) rankMentions(needle string) { for _, team := range c.teams { if _, ok := pathScore(team.name+" "+team.slug, needle); ok { c.teamHits = append(c.teamHits, team) - if len(c.teamHits) >= mentionRows { + if len(c.teamHits) >= mentionCap(c.scope) { break } } @@ -104,7 +114,7 @@ func (c *completion) rankMentions(needle string) { hay := chat.title + " " + chat.handle + " " + chat.slug if _, ok := pathScore(hay, needle); ok { c.chatHits = append(c.chatHits, chat) - if len(c.chatHits) >= mentionRows { + if len(c.chatHits) >= mentionCap(c.scope) { break } } @@ -112,6 +122,15 @@ func (c *completion) rankMentions(needle string) { } } +// mentionCap is how many rows one section keeps: a screenful on the mixed +// list, the file list's own cap once a prefix has made it the only section. +func mentionCap(scope string) int { + if scope == "" { + return mentionRows + } + return mentionRowsScoped +} + // layoutMentions appends the team section and the conversation section. func (c *completion) layoutMentions(lines []compLine) []compLine { if len(c.teamHits) > 0 { @@ -219,6 +238,13 @@ func mentionToken(chat mentionChat) string { func (a *app) fillMentions() { a.comp.teams = a.mentionTeams() a.comp.chats = a.mentionChats() + a.comp.recentsLoaded = a.mentionRecentsReady(true) +} + +// mentionRecentsReady distinguishes an unanswered read from a settled catalog. +// Token openings invalidate it through the read itself, never a display close. +func (a *app) mentionRecentsReady(_ bool) bool { + return a.recentSessions == nil || !a.comp.recentsPending && (a.comp.recentsLoaded || len(a.comp.recents) > 0) } func (a *app) mentionTeams() []mentionTeam { @@ -242,12 +268,30 @@ func (a *app) mentionTeams() []mentionTeam { // mentionChats is the open conversations in this window, then recent ones that // are not already open. The conversation in front is left off: pointing at the // chat you are typing in is not a reference. +// +// ON THE START PAGE NOTHING IS LEFT OFF. `+` stands the conversation's unit down +// and points the box at a composer of its own, but it opens no file: the +// window still carries the conversation it came from as the one in front +// (chatstart.go's [app.openChatStart]). The sentence being typed there opens a +// NEW conversation, so that one is a reference like any other — and leaving it +// off was the owner typing `@chat:kim` on the page with `tell me about kim jung +// il` lit on the strip beside it, and reading `no conversation matches`. func (a *app) mentionChats() []mentionChat { - front := a.frontTabKey() + if a.startingChat() { + return a.mentionChatsExcept("") + } + return a.mentionChatsExcept(a.frontTabKey()) +} + +// mentionChatsExcept is that catalog with one conversation left off, or none +// for "". Home's box leaves none off: its sentence opens a NEW conversation, +// and the one behind the screen is as much a reference as any other +// (homeat.go's [app.fillHomeMentions]). +func (a *app) mentionChatsExcept(front string) []mentionChat { var out []mentionChat seen := map[string]bool{} for _, tab := range a.tabList() { - if tab.slot || tab.key == "" || tab.key == front || seen[tab.key] { + if tab.slot || tab.key == "" || tab.key == front || seen[tab.key] || !a.mentionableTab(tab) { continue } seen[tab.key] = true @@ -264,6 +308,27 @@ func (a *app) mentionChats() []mentionChat { return out } +// mentionableTab leaves off only the front's own unsent shell. Other tabs are +// offered without guessing their sent history from a name or sidecar. +func (a *app) mentionableTab(tab chatTab) bool { + if tab.key != a.frontTabKey() { + return true + } + named := func(title string) bool { + title = strings.TrimSpace(title) + return title != "" && title != unnamedConversationWord + } + if named(a.title) || strings.TrimSpace(a.openingPrompt) != "" { + return true + } + for _, e := range a.entries { + if e.kind == entryUser { + return true + } + } + return false +} + func (a *app) mentionFromTab(tab chatTab, open bool) mentionChat { title := strings.TrimSpace(tab.full) if title == "" { @@ -300,35 +365,82 @@ func (a *app) mentionHandle(key string) string { } // mentionRecentsMsg is the recent list, read off the loop. -type mentionRecentsMsg struct{ rows []Session } +type mentionRecentsMsg struct { + rows []Session + keys []string + read uint64 +} -// loadMentionRecents reads the door's recent list once. The door's function -// may open a directory, so it runs inside the command and not on the loop. +// loadMentionRecents reads the door's recent list. The door's function may +// open a directory, so it runs inside the command and not on the loop. +// +// EVERY OPENING ASKS FOR FRESH ROWS, not just the process's first one. A new @ +// token or the list returning after Escape starts an opening, independently of +// automatic display closes ([completion.beginToken]). Only one walk may be in +// flight per window. Openings while it runs owe one follow-up after it lands; +// its earlier answer cannot settle their search as fresh. The door returns at +// most twenty recent conversations (cmd/codeaf's v3RecentSessions), but may +// examine many more session folders, so overlapping its walks is unbounded work. func (a *app) loadMentionRecents() tea.Cmd { - if a.comp.recentsHeld || a.recentSessions == nil { + if a.recentSessions == nil || a.comp.recentsHeld && !a.comp.recentsPending { + return nil + } + if a.comp.recentsPending { + a.comp.recentsAgain = true return nil } - a.comp.recentsHeld = true - read := a.recentSessions + a.comp.recentsHeld, a.comp.recentsLoaded, a.comp.recentsPending = true, false, true + a.comp.recentsRead++ + readID := a.comp.recentsRead + read, hosted := a.recentSessions, a.hosted() return func() tea.Msg { list := read() if len(list) > mentionRecentCap { list = list[:mentionRecentCap] } - return mentionRecentsMsg{rows: list} + // Canonical keys travel with the read because resolving a symlink is + // disk work. The hosted rule is captured before this closure runs. + keys := make([]string, len(list)) + for i, row := range list { + file := strings.TrimSpace(row.File) + if file == "" { + continue + } + if hosted { + keys[i] = filepath.Clean(file) + } else { + keys[i] = convKey(file) + } + } + return mentionRecentsMsg{rows: list, keys: keys, read: readID} } } -func (a *app) mentionRecentsLoaded(rows []Session) { - a.comp.recentsHeld = true +// mentionRecentsLoaded folds in keys already computed by the read. A message +// supplied without keys uses cleaned spellings and never resolves local files. +func (a *app) mentionRecentsLoaded(rows []Session, keys ...string) { + // The read has landed, so the next opening of the list may ask again. + a.comp.recentsHeld, a.comp.recentsLoaded, a.comp.recentsPending = false, true, false + a.comp.recentsAgain = false a.comp.recents = a.comp.recents[:0] seen := map[string]bool{} - for _, row := range rows { + for i, row := range rows { file := strings.TrimSpace(row.File) - if file == "" || seen[file] { + // THE KEY IS THE CANONICAL FILE, the same spelling every tab carries + // (chattabs.go's [chatTab.key]). Keyed on the row's own spelling, a + // home reached through a symlink listed the conversation in front, + // and every open tab a second time, as recent rows: `/tmp` is + // `/private/tmp` on a Mac, and the walk spells what it was given. + key := "" + if i < len(keys) { + key = keys[i] + } else if file != "" { + key = filepath.Clean(file) + } + if key == "" || seen[key] { continue } - seen[file] = true + seen[key] = true title := strings.TrimSpace(row.Title) if title == "" { title = strings.TrimSpace(row.Opening) @@ -337,14 +449,20 @@ func (a *app) mentionRecentsLoaded(rows []Session) { continue } a.comp.recents = append(a.comp.recents, mentionChat{ - key: file, file: file, title: title, - handle: a.mentionHandle(file), slug: mentionSlug(title), + key: key, file: file, title: title, + handle: a.mentionHandle(key), slug: mentionSlug(title), note: title, }) } if a.comp.open { a.fillMentions() - a.comp.rank() + a.comp.refresh(&a.input) + } + // AND HOME'S LIST IS THE OTHER READER OF THE SAME SNAPSHOT (homeat.go). + if a.home.comp.open { + a.fillHomeMentions() + a.home.comp.rank() + a.home.build() } a.touch() } @@ -355,37 +473,47 @@ func (a *app) mentionRecentsLoaded(rows []Session) { // comes out: the bullet is the mark, and a second mark in front of it would // be two names for one thing. func (a *app) completeTeam(team mentionTeam) { + completeTeamIn(&a.input, &a.comp, team) + a.touch() +} + +// completeTeamIn is that edit on ANY box its list is bound to — the +// conversation's, or home's (homeat.go) — so the two cannot grow two spellings +// of what choosing a team types. +func completeTeamIn(e *editor, c *completion, team mentionTeam) { slug := team.slug if slug == "" { slug = mentionSlug(team.name) } token := "●" + slug - e := &a.input - head := append([]rune(nil), e.value[:a.comp.at]...) + head := append([]rune(nil), e.value[:c.at]...) tail := append([]rune(nil), e.value[e.cursor:]...) e.value = append(append(head, []rune(token)...), tail...) - e.cursor = a.comp.at + len([]rune(token)) - a.comp.done = "" - a.comp.close() - a.touch() + e.cursor = c.at + len([]rune(token)) + c.done = "" + c.close() } // completeChat types "@" and the handle, or the title's slug when the // conversation has no handle. func (a *app) completeChat(chat mentionChat) { + completeChatIn(&a.input, &a.comp, chat) + a.touch() +} + +// completeChatIn is that edit on any box, for [completeTeamIn]'s reason. +func completeChatIn(e *editor, c *completion, chat mentionChat) { token := mentionToken(chat) if token == "" { - a.comp.close() + c.close() return } - e := &a.input - head := append([]rune(nil), e.value[:a.comp.at+1]...) + head := append([]rune(nil), e.value[:c.at+1]...) tail := append([]rune(nil), e.value[e.cursor:]...) e.value = append(append(head, []rune(token)...), tail...) - e.cursor = a.comp.at + 1 + len([]rune(token)) - a.comp.done = token - a.comp.close() - a.touch() + e.cursor = c.at + 1 + len([]rune(token)) + c.done = token + c.close() } // ── the prefix words ──────────────────────────────────────────────────────── @@ -486,18 +614,16 @@ func (a *app) mentionHeadHint() string { // paintDraftMentions colours a "●slug" in the box with its team's colour. The // runes stay the runes, so the caret's column does not move. func (a *app) paintDraftMentions(block []string) []string { - if len(a.comp.teams) == 0 && len(a.wall.teams) == 0 { - return block - } - teams := a.comp.teams - if len(teams) == 0 { - teams = a.mentionTeams() - } + var teams []mentionTeam for i, line := range block { - plain := ansi.Strip(line) - if !strings.Contains(plain, "●") { + if !strings.Contains(line, "●") { continue } + // Only a row containing a team mark pays for the current catalog. The + // conversation's completion snapshot may predate a team edit or adoption. + if teams == nil { + teams = a.mentionTeams() + } for _, team := range teams { token := "●" + team.slug if !strings.Contains(line, token) { diff --git a/internal/tui3/mention_test.go b/internal/tui3/mention_test.go index 3bddf37cb..e73d10f85 100644 --- a/internal/tui3/mention_test.go +++ b/internal/tui3/mention_test.go @@ -3,6 +3,7 @@ package tui3 import ( "strings" "testing" + "time" tea "charm.land/bubbletea/v2" "github.com/charmbracelet/x/ansi" @@ -170,3 +171,139 @@ func TestMentionFrameDoesNotReadRecents(t *testing.T) { _ = a.View() a.mentionLinkPass([]row{{entry: 0, text: "●harbor"}}, []entry{{kind: entryUser}}) } + +// TestMentionRecentsAreReadAgainOnEachOpening is the list against a +// conversation that did not exist, or had no name, the first time "@" opened +// in this window. A snapshot taken once per process kept it off the list for +// as long as the window lived, with its title in plain sight on a tab strip. +func TestMentionRecentsAreReadAgainOnEachOpening(t *testing.T) { + a := mentionApp(t) + a.comp.recentsHeld, a.comp.recents = false, nil + reads := 0 + rows := []Session{{Title: "openrouter price scrape", File: "/s/price.jsonl"}} + a.recentSessions = func() []Session { + reads++ + return append([]Session(nil), rows...) + } + typeInto(t, a, "@chat:") + if reads != 1 { + t.Fatalf("the first opening read the recent list %d times", reads) + } + if len(a.comp.chatHits) != 1 || a.comp.chatHits[0].title != "openrouter price scrape" { + t.Fatalf("the first opening lists %+v", a.comp.chatHits) + } + drive(t, a, key("esc")) + if a.comp.open { + t.Fatal("esc did not close the list") + } + rows = append(rows, Session{Title: "Cloudflare worker deploy", File: "/s/cloudflare.jsonl"}) + typeInto(t, a, " @chat:cloudfl") + if reads != 2 { + t.Fatalf("the second opening left the recent list at %d reads", reads) + } + if len(a.comp.chatHits) != 1 || a.comp.chatHits[0].title != "Cloudflare worker deploy" { + t.Fatalf("a conversation started after the first opening is not on the list: %+v", a.comp.chatHits) + } + // And the letters after the "@" do not walk the disk again. + typeInto(t, a, "are") + if reads != 2 { + t.Fatalf("typing into an open list read the recent list again: %d reads", reads) + } +} + +// TestChatPrefixKeepsEveryConversation: the mixed list keeps a screenful of +// conversations beside the teams, tasks and files; "@chat:" is the person +// asking for conversations and nothing else, and it keeps the whole catalog +// rather than the first eight. +func TestChatPrefixKeepsEveryConversation(t *testing.T) { + a := mentionApp(t) + a.comp.recents = a.comp.recents[:0] + for i := 0; i < mentionRows+4; i++ { + a.comp.recents = append(a.comp.recents, mentionChat{ + key: "/s/chat-" + itoa(i) + ".jsonl", file: "/s/chat-" + itoa(i) + ".jsonl", + title: "chat " + itoa(i), slug: "chat-" + itoa(i), note: "chat " + itoa(i), + }) + } + typeInto(t, a, "@") + if len(a.comp.chatHits) != mentionRows { + t.Fatalf("the mixed list keeps %d conversations, and a screenful is %d", len(a.comp.chatHits), mentionRows) + } + a = mentionApp(t) + a.comp.recents = a.comp.recents[:0] + for i := 0; i < mentionRows+4; i++ { + a.comp.recents = append(a.comp.recents, mentionChat{ + key: "/s/chat-" + itoa(i) + ".jsonl", file: "/s/chat-" + itoa(i) + ".jsonl", + title: "chat " + itoa(i), slug: "chat-" + itoa(i), note: "chat " + itoa(i), + }) + } + typeInto(t, a, "@chat:") + if len(a.comp.chatHits) != mentionRows+4 { + t.Fatalf("the chat prefix keeps %d conversations of %d", len(a.comp.chatHits), mentionRows+4) + } + // The last one is reachable: the cursor walks past the first screenful. + for i := 0; i < mentionRows+3; i++ { + drive(t, a, key("down")) + } + if chat, ok := a.comp.chatChoice(); !ok || chat.slug != "chat-"+itoa(mentionRows+3) { + t.Fatalf("the cursor stopped on %+v", chat) + } +} + +// TestMentionRecentsAreKeyedLikeTabs: a recent row is the same conversation as +// a tab when their canonical files agree, whatever spelling the walk used. A +// home reached through a symlink used to list the conversation in front, and +// every open tab a second time, as recent rows. +func TestMentionRecentsAreKeyedLikeTabs(t *testing.T) { + a := mentionApp(t) + emptyMachine(a) + a.file = "/tmp/lab/this-one.jsonl" + a.openingPrompt = "the current conversation" + a.stow(Conversation{ + Agent: &fakeAgent{model: "m"}, SessionFile: "/tmp/lab/cf.jsonl", + Workspace: "/tmp/lab", Place: "lab", + }, &aside{since: a.now().Add(-12 * time.Minute), title: "Cloudflare worker deploy"}) + _ = a.tabsRow(a.width) + a.comp.recentsHeld, a.comp.recents = false, nil + a.recentSessions = func() []Session { + return []Session{ + {Title: "the current conversation", File: "/tmp/lab/x/../this-one.jsonl"}, + {Title: "Cloudflare worker deploy", File: "/tmp/lab/x/../cf.jsonl"}, + {Title: "openrouter price scrape", File: "/tmp/lab/x/../price.jsonl"}, + } + } + typeInto(t, a, "@chat:") + var got []string + for _, chat := range a.comp.chatHits { + got = append(got, chat.title+"/"+chat.key) + } + if len(got) != 2 || got[0] != "Cloudflare worker deploy//tmp/lab/cf.jsonl" || got[1] != "openrouter price scrape//tmp/lab/price.jsonl" { + t.Fatalf("the list is %q", got) + } +} + +// TestTheStartPageLeavesNoConversationOffTheList: `+` opens no file, so the +// window still carries the conversation it came from as the one in front. The +// sentence typed on the page opens a NEW conversation, so that one is on the +// list — it was left off as though the person were typing inside it, and +// `@chat:kim` beside a lit `tell me about kim jung il` said nothing matched. +func TestTheStartPageLeavesNoConversationOffTheList(t *testing.T) { + lab := newStartLab(t) + a := lab.app() + a.title = "tell me about kim jung il" + a.comp.recentsHeld = true + openStart(t, a) + if !a.startingChat() { + t.Fatal("+ did not open the start page") + } + typeInto(t, a, "@chat:kim") + if !a.comp.open { + t.Fatal("@chat:kim did not open the list on the start page") + } + if len(a.comp.chatHits) != 1 || a.comp.chatHits[0].title != "tell me about kim jung il" { + t.Fatalf("the start page's list holds %+v, and the conversation it came from is open", a.comp.chatHits) + } + drive(t, a, key("enter")) + if got := a.input.String(); got != "@tell-me-about-kim-jung-il" { + t.Fatalf("choosing it typed %q", got) + } +} diff --git a/internal/tui3/pages.go b/internal/tui3/pages.go index bb3ef7b50..1e32e167f 100644 --- a/internal/tui3/pages.go +++ b/internal/tui3/pages.go @@ -1013,6 +1013,7 @@ func placeFrameWithBar(a *app, width, height int, // Blank lines still hold the caret. The send-time emptiness check // discards whitespace, but the editor must draw everything typed. draftRows, draftCX, draftCY = draftBlockWithTags(box, pal, width-2, homeDraftRows, "", "", nil, pal.ink) + draftRows = a.paintDraftMentions(draftRows) } // THE BOX HAS A FLOOR ([boxFloor]) AND EVERY BRANCH ABOVE IS HELD TO IT, the // target's filter included. The rows that make it up are added BELOW what was diff --git a/internal/tui3/place_home.go b/internal/tui3/place_home.go index a204b8390..c0629f33b 100644 --- a/internal/tui3/place_home.go +++ b/internal/tui3/place_home.go @@ -549,9 +549,12 @@ func (placeHome) wheel(a *app, delta int) (tea.Cmd, bool) { return nil, false } // (homecardread.go). Asking costs a map lookup on every key that changed // nothing, which is what it costs to never be stale. func (placeHome) key(a *app, msg tea.KeyPressMsg) tea.Cmd { + // THE `@` LIST'S CATALOGS ARE COPIED BEFORE THE KEY IS READ, because the + // key may be the one that ranks them (homeat.go's [app.fillHomeMentions]). + was := a.home.comp.open + a.prepareHomeCompletion(msg.Text) answered := a.homeKey(msg) - // AND THE `@` LIST'S WALK STARTS THE KEY THAT OPENED IT (homeat.go). - return tea.Batch(answered, a.loadHomeFiles(), a.refreshHomeCard(a.now())) + return tea.Batch(answered, a.syncHomeCompletion(was), a.refreshHomeCard(a.now())) } // owns is the two layers of home that take the WHOLE keyboard, `tab` included, @@ -579,7 +582,7 @@ func (placeHome) owns(a *app, msg tea.KeyPressMsg) (tea.Cmd, bool) { // full-frame card a thumb is in the middle of, and the phone's rule names no // chord at all — there is no `alt` on a phone to press. if cmd, took := a.placeTargetKey(msg); took { - return cmd, true + return tea.Batch(cmd, a.syncHomeCompletion(true)), true } a.settleExchangeFocus() ex := a.paneExchange()