From 4e37de6d73b3025b779695e419c9b985a504094f Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 1 Oct 2026 10:55:37 -0400 Subject: [PATCH 01/10] chat: the @ list reads recent conversations on every opening and @chat shows them all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Santosh reported a tab reading `cloudfl…` on the strip that `@chat:cloudfl` did not list. The match 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, so a conversation started in another window after that, or named after it, was never on the list. It is read again every time the list opens, and not on the letters typed after the `@`. - A section was cut at eight rows with no sign of the rest. `@chat:` and `@team:` keep up to thirty-two and scroll; the bare `@` keeps eight per section beside the tasks and files. - Recent rows are keyed by the canonical transcript, the way tabs are, so a home reached through a symlink no longer lists the conversation in front, or every open tab a second time, as recent rows. - The manual says which conversations the list holds: every open tab except the one you are typing in, then the twenty most recent in this project. Co-Authored-By: Claude Fable 5.1 --- ...720-mention-list-recents-and-chat-scope.md | 17 +++ internal/manual/chat/attaching-files.md | 5 + .../manual/chat/conversations-and-teams.md | 19 ++- internal/manual/chat/keys.md | 5 + internal/tui3/files.go | 9 +- internal/tui3/mention.go | 63 +++++++--- internal/tui3/mention_test.go | 110 ++++++++++++++++++ 7 files changed, 207 insertions(+), 21 deletions(-) create mode 100644 docs/changes/unreleased/1720-mention-list-recents-and-chat-scope.md diff --git a/docs/changes/unreleased/1720-mention-list-recents-and-chat-scope.md b/docs/changes/unreleased/1720-mention-list-recents-and-chat-scope.md new file mode 100644 index 0000000000..34fd82e4bd --- /dev/null +++ b/docs/changes/unreleased/1720-mention-list-recents-and-chat-scope.md @@ -0,0 +1,17 @@ +--- +kind: fixed +title: the @ list reads recent conversations on every opening and @chat shows them all +pr: 1720 +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 again every time the list opens." + - "`@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, then the twenty most recent in this project; older ones and other projects' are reached with `/resume`." +--- + +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 c62c2edb86..b1b721fac0 100644 --- a/internal/manual/chat/attaching-files.md +++ b/internal/manual/chat/attaching-files.md @@ -561,6 +561,11 @@ 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 conversations on it are the tabs open in this window, except the one you are +typing in, and then the twenty most recent in this project, read again each time +the list opens. One older than that, or in another project, is not on it. The bare +`@` shows eight of them; `@chat:` shows them all and scrolls. + 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, diff --git a/internal/manual/chat/conversations-and-teams.md b/internal/manual/chat/conversations-and-teams.md index 115e0581b5..3720b8f7e4 100644 --- a/internal/manual/chat/conversations-and-teams.md +++ b/internal/manual/chat/conversations-and-teams.md @@ -313,13 +313,26 @@ 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. + +**Which conversations are on the list, and which are not.** Every tab open in this +window except the one you are typing in: pointing at the conversation you are in is +not a reference, so it is left off. Then the twenty most recent conversations in this +project, by when you last spoke in them, read from the disk again each time the list +opens, so a conversation started in another window a minute ago is on it. Over +`--host` those are the far machine's. A conversation older than those twenty, or in +another project, is not on the list: `/resume` is the way to it. 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. +filters every section that is still showing. Matching is by prefix, then substring, +then the letters in order — `cloudfl`, `worker` and `cfwd` all find `Cloudflare worker +deploy` — against the title, the handle and the slug, never the transcript. + +The bare `@` keeps eight teams and eight conversations beside the tasks and files; +`@chat:` keeps every conversation on the list (thirty-two at most) and scrolls. 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 diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index fee4da0a11..17c3c4670f 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -1535,6 +1535,11 @@ the list keeps only that section. Press the same word again and the prefix comes 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. +The conversations section is the tabs open in this window, except the one you are +typing in, then the twenty most recent conversations in this project, read again on +each opening of the list. The bare `@` keeps eight; `@chat:` keeps them all and +scrolls. + **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 diff --git a/internal/tui3/files.go b/internal/tui3/files.go index a49cb9504a..8283c0a971 100644 --- a/internal/tui3/files.go +++ b/internal/tui3/files.go @@ -120,8 +120,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, and read again + // each time the list opens so a conversation started since is on it. teams []mentionTeam chats []mentionChat recents []mentionChat @@ -131,7 +132,9 @@ 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 // lines is what the overlay DRAWS, section rules included, and sel is the diff --git a/internal/tui3/mention.go b/internal/tui3/mention.go index bfb3fa5244..a8a1651c93 100644 --- a/internal/tui3/mention.go +++ b/internal/tui3/mention.go @@ -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 { @@ -302,8 +321,15 @@ func (a *app) mentionHandle(key string) string { // mentionRecentsMsg is the recent list, read off the loop. type mentionRecentsMsg struct{ rows []Session } -// 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. +// +// IT RUNS ON EVERY OPENING OF THE LIST, not once per process. The read is the +// door's own bounded walk — twenty transcripts at most, on the keystroke that +// asks (cmd/codeaf's v3RecentSessions) — and the keystroke is "@", not every +// letter after it: [app.syncLists] asks only when the list was closed and is +// now open. One read is held at a time; a second "@" while the first is still +// walking waits for that answer rather than starting another walk. func (a *app) loadMentionRecents() tea.Cmd { if a.comp.recentsHeld || a.recentSessions == nil { return nil @@ -320,15 +346,22 @@ func (a *app) loadMentionRecents() tea.Cmd { } func (a *app) mentionRecentsLoaded(rows []Session) { - a.comp.recentsHeld = true + // The read has landed, so the next opening of the list may ask again. + a.comp.recentsHeld = false a.comp.recents = a.comp.recents[:0] seen := map[string]bool{} for _, 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 := a.convKey(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,8 +370,8 @@ 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, }) } diff --git a/internal/tui3/mention_test.go b/internal/tui3/mention_test.go index 3bddf37cbe..ed7c7d7022 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,112 @@ 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) + } +} From d6d02077075e3f872ce0517bd4b2e7ae8c1dc6be Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:34:26 -0400 Subject: [PATCH 02/10] home: the @ list on home offers teams and conversations with the same prefixes Home's box had a files-only `@` list, so `@chat:` typed there answered `no file matches`. One search behaves one way on every box: home's list now has the prefix words on its first row, the teams and conversations sections above the files, and the same `@team:`, `@chat:` and `@file:` prefixes. The catalogs are the same memory the conversation's list copies, the recent list is read again on each opening, and choosing a team or a conversation types exactly what it types in a conversation, through the one function both boxes now share. Home leaves no conversation off, because its sentence opens a new one. Co-Authored-By: Claude Fable 5.1 --- ...720-mention-list-recents-and-chat-scope.md | 1 + internal/manual/chat/attaching-files.md | 3 +- .../manual/chat/conversations-and-teams.md | 4 + internal/manual/chat/home.md | 35 +++-- internal/tui3/home.go | 14 +- internal/tui3/homeat.go | 146 ++++++++++++++---- internal/tui3/homeat_test.go | 110 +++++++++++++ internal/tui3/homephone.go | 7 +- internal/tui3/mention.go | 51 ++++-- internal/tui3/place_home.go | 14 +- 10 files changed, 320 insertions(+), 65 deletions(-) diff --git a/docs/changes/unreleased/1720-mention-list-recents-and-chat-scope.md b/docs/changes/unreleased/1720-mention-list-recents-and-chat-scope.md index 34fd82e4bd..bf37442cac 100644 --- a/docs/changes/unreleased/1720-mention-list-recents-and-chat-scope.md +++ b/docs/changes/unreleased/1720-mention-list-recents-and-chat-scope.md @@ -7,6 +7,7 @@ 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 again every time the list opens." - "`@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, then the twenty most recent in this project; older ones and other projects' are reached with `/resume`." + - "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 conversation off." --- Santosh's report (2026-09-30): a tab reading `cloudfl…` was on the strip and diff --git a/internal/manual/chat/attaching-files.md b/internal/manual/chat/attaching-files.md index b1b721fac0..807619263c 100644 --- a/internal/manual/chat/attaching-files.md +++ b/internal/manual/chat/attaching-files.md @@ -564,7 +564,8 @@ the word under the pointer takes a background, and the hint names the key, `clic The conversations on it are the tabs open in this window, except the one you are typing in, and then the twenty most recent in this project, read again each time the list opens. One older than that, or in another project, is not on it. The bare -`@` shows eight of them; `@chat:` shows them all and scrolls. +`@` 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 conversation off. 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 diff --git a/internal/manual/chat/conversations-and-teams.md b/internal/manual/chat/conversations-and-teams.md index 3720b8f7e4..14a65421dc 100644 --- a/internal/manual/chat/conversations-and-teams.md +++ b/internal/manual/chat/conversations-and-teams.md @@ -334,6 +334,10 @@ deploy` — against the title, the handle and the slug, never the transcript. The bare `@` keeps eight teams and eight conversations beside the tasks and files; `@chat:` keeps every conversation on the list (thirty-two at most) and scrolls. +**Home's box has the same list**, the same sections and the same three prefixes, with one +difference: it leaves no conversation off, because a sentence sent from home opens a new +one. The three words on its first row are not buttons there; type the prefix. + 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 diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index 0cc0ad8e0d..d7a7ca1294 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -1475,19 +1475,28 @@ 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 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. `@team:` +keeps only teams, `@chat:` only conversations, `@file:` only files, exactly as in a +conversation. The conversations are every tab open in this window — including the one +behind home, since the sentence opens a new conversation — then the twenty most recent +in this project, read again each time the list opens. + +`↑`/`↓` 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 — 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 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. ## How do I get back to the dashboard or the home screen from any page — press space twice diff --git a/internal/tui3/home.go b/internal/tui3/home.go index 9095dfbb80..5f4227574e 100644 --- a/internal/tui3/home.go +++ b/internal/tui3/home.go @@ -1516,7 +1516,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 } @@ -3197,8 +3197,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())) @@ -4638,7 +4639,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: @@ -4772,8 +4773,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 e94276c7b3..cff316a860 100644 --- a/internal/tui3/homeat.go +++ b/internal/tui3/homeat.go @@ -22,29 +22,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 read again on each opening 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,33 +75,85 @@ 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: + return mentionTeamLabel(c.teamHits[cl.team], pal), c.lineNote(line.comp), true + case cl.chat >= 0: + return mentionChatLabel(c.chatHits[cl.chat]), c.lineNote(line.comp), true + case cl.file >= 0: + 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 + } + return c.all[c.lines[line.comp].file], true +} + +// fillHomeMentions copies the in-memory catalogs onto home's list, the way +// [app.fillMentions] copies them onto the conversation's. It runs on the key +// that 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("") } // loadHomeFiles walks the target folder for the list, once per target: a @@ -120,13 +195,30 @@ func (a *app) homeFilesLoaded(paths []string) { 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: + completeTeamIn(&h.box, c, c.teamHits[cl.team]) + h.build() + a.touch() + return nil + case cl.chat >= 0: + 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() diff --git a/internal/tui3/homeat_test.go b/internal/tui3/homeat_test.go index 4c5ac779dc..6622c3c3a7 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 deb6eefe5c..f504324904 100644 --- a/internal/tui3/homephone.go +++ b/internal/tui3/homephone.go @@ -643,6 +643,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 +692,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 a8a1651c93..c36818d961 100644 --- a/internal/tui3/mention.go +++ b/internal/tui3/mention.go @@ -262,7 +262,14 @@ func (a *app) mentionTeams() []mentionTeam { // are not already open. The conversation in front is left off: pointing at the // chat you are typing in is not a reference. func (a *app) mentionChats() []mentionChat { - front := a.frontTabKey() + 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() { @@ -379,6 +386,12 @@ func (a *app) mentionRecentsLoaded(rows []Session) { a.fillMentions() a.comp.rank() } + // 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() } @@ -388,37 +401,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 ──────────────────────────────────────────────────────── diff --git a/internal/tui3/place_home.go b/internal/tui3/place_home.go index a204b8390c..4f835da91c 100644 --- a/internal/tui3/place_home.go +++ b/internal/tui3/place_home.go @@ -549,9 +549,19 @@ 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]). + a.fillHomeMentions() + was := a.home.comp.open 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())) + // AND THE `@` LIST'S WALK STARTS THE KEY THAT OPENED IT (homeat.go), and + // so does the read of the recent conversations, on the opening and not on + // the letters after it (mention.go's [app.loadMentionRecents]). + var recents tea.Cmd + if a.home.comp.open && !was { + recents = a.loadMentionRecents() + } + return tea.Batch(answered, a.loadHomeFiles(), recents, a.refreshHomeCard(a.now())) } // owns is the two layers of home that take the WHOLE keyboard, `tab` included, From 4b36ac4ba437582ce25f7527daba85f836603655 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:45:16 -0400 Subject: [PATCH 03/10] chat: the new-chat page's @ list leaves no conversation off `+` opens no file, so the window still carries the conversation it came from as the one in front, and the list left it off as though the person were typing inside it: `@chat:kim` beside a lit `tell me about kim jung il` said `no conversation matches`. The sentence typed on the start page opens a new conversation, so every open one is a reference there. Co-Authored-By: Claude Fable 5.1 --- ...720-mention-list-recents-and-chat-scope.md | 1 + .../manual/chat/conversations-and-teams.md | 4 ++- internal/tui3/mention.go | 11 ++++++++ internal/tui3/mention_test.go | 27 +++++++++++++++++++ 4 files changed, 42 insertions(+), 1 deletion(-) diff --git a/docs/changes/unreleased/1720-mention-list-recents-and-chat-scope.md b/docs/changes/unreleased/1720-mention-list-recents-and-chat-scope.md index bf37442cac..c43ee62fab 100644 --- a/docs/changes/unreleased/1720-mention-list-recents-and-chat-scope.md +++ b/docs/changes/unreleased/1720-mention-list-recents-and-chat-scope.md @@ -7,6 +7,7 @@ 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 again every time the list opens." - "`@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, then the twenty most recent in this project; older ones and other projects' are reached with `/resume`." + - "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 conversation off." - "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 conversation off." --- diff --git a/internal/manual/chat/conversations-and-teams.md b/internal/manual/chat/conversations-and-teams.md index 14a65421dc..41066bb2ff 100644 --- a/internal/manual/chat/conversations-and-teams.md +++ b/internal/manual/chat/conversations-and-teams.md @@ -318,7 +318,9 @@ conversation in no team is on that list. **Which conversations are on the list, and which are not.** Every tab open in this window except the one you are typing in: pointing at the conversation you are in is -not a reference, so it is left off. Then the twenty most recent conversations in this +not a reference, so it is left off. On the new-chat page (`+` on the strip, or `ctrl+t`) +nothing is left off, because the sentence typed there opens a new conversation — the +one you came from is on the list. Then the twenty most recent conversations in this project, by when you last spoke in them, read from the disk again each time the list opens, so a conversation started in another window a minute ago is on it. Over `--host` those are the far machine's. A conversation older than those twenty, or in diff --git a/internal/tui3/mention.go b/internal/tui3/mention.go index c36818d961..890e99a6aa 100644 --- a/internal/tui3/mention.go +++ b/internal/tui3/mention.go @@ -261,7 +261,18 @@ 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 { + if a.startingChat() { + return a.mentionChatsExcept("") + } return a.mentionChatsExcept(a.frontTabKey()) } diff --git a/internal/tui3/mention_test.go b/internal/tui3/mention_test.go index ed7c7d7022..e73d10f85e 100644 --- a/internal/tui3/mention_test.go +++ b/internal/tui3/mention_test.go @@ -280,3 +280,30 @@ func TestMentionRecentsAreKeyedLikeTabs(t *testing.T) { 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) + } +} From a9b111a82dc90800f72b5bbf85091f9e485208c9 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:21:05 -0400 Subject: [PATCH 04/10] chat: the words after @ may have spaces, and every box opens the same list `@chat:who` found `who is kim jong il` and `@chat:who is` closed the list, because the token stopped at a space. It now walks back to the nearest `@` that begins a word, over up to three spaces, and every word of the needle must match, in any order, on teams, conversations and files alike: `@chat:who is`, `@team:har bor`, `@file:tui3 app`. Several words that match nothing close the list, since that is a sentence after a mention; one word that matches nothing still says so. One test now drives the same fixture and the same searches through a conversation's box, home's box and the new-chat page's box, so the three cannot drift apart again. Co-Authored-By: Claude Fable 5.1 --- ...720-mention-list-recents-and-chat-scope.md | 1 + .../manual/chat/conversations-and-teams.md | 8 +- internal/manual/chat/keys.md | 9 +- internal/tui3/atspaces_test.go | 235 ++++++++++++++++++ internal/tui3/files.go | 72 +++++- 5 files changed, 314 insertions(+), 11 deletions(-) create mode 100644 internal/tui3/atspaces_test.go diff --git a/docs/changes/unreleased/1720-mention-list-recents-and-chat-scope.md b/docs/changes/unreleased/1720-mention-list-recents-and-chat-scope.md index c43ee62fab..689d054ced 100644 --- a/docs/changes/unreleased/1720-mention-list-recents-and-chat-scope.md +++ b/docs/changes/unreleased/1720-mention-list-recents-and-chat-scope.md @@ -8,6 +8,7 @@ invalidates: - "`@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, then the twenty most recent in this project; older ones and other projects' are reached with `/resume`." - "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 conversation off." + - "The words after an `@` could not hold a space: `@chat:who is` closed the list that `@chat:who` had opened. The token walks back over up to three spaces, every word must match in any order, on teams, conversations and files alike, and a multi-word search that matches nothing closes the list." - "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 conversation off." --- diff --git a/internal/manual/chat/conversations-and-teams.md b/internal/manual/chat/conversations-and-teams.md index 41066bb2ff..111bd3071f 100644 --- a/internal/manual/chat/conversations-and-teams.md +++ b/internal/manual/chat/conversations-and-teams.md @@ -331,7 +331,13 @@ background under the pointer and a one-line hint (`only teams · click`). A pres types `@team:`, `@chat:` or `@file:`, and the list keeps only that section. Typing filters every section that is still showing. Matching is by prefix, then substring, then the letters in order — `cloudfl`, `worker` and `cfwd` all find `Cloudflare worker -deploy` — against the title, the handle and the slug, never the transcript. +deploy` — against the title, the handle and the slug, never the transcript. The words +may have spaces in them, up to three: `@chat:who is` finds `who is kim jong il`, and +`deploy worker` finds it too, because every word must match, in any order. That is the +same rule on every section — `@team:har bor`, `@file:tui3 app` — and on every box: a +conversation's, home's and the new-chat page's. Several words that match nothing close +the list, since that is a sentence after a mention; one word that matches nothing says +`no conversation matches`. The bare `@` keeps eight teams and eight conversations beside the tasks and files; `@chat:` keeps every conversation on the list (thirty-two at most) and scrolls. diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index 17c3c4670f..f43d15eebb 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -1545,8 +1545,13 @@ marked `folder` on the right the way a picture row is marked `img`. Choosing one 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 — +The token is found by walking back from the caret to the nearest `@` that begins a +word, over at most three spaces, never past a newline. So the words after the `@` may +have spaces in them — `@chat:who is` finds `who is kim jong il`, `@internal tui3` finds +`internal/tui3/` — and every word must match, in any order. A search of several words +that matches nothing closes the list, because that is a sentence after a mention and +not a search; one word that matches nothing still says `no matches`. 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 diff --git a/internal/tui3/atspaces_test.go b/internal/tui3/atspaces_test.go new file mode 100644 index 0000000000..473519820a --- /dev/null +++ b/internal/tui3/atspaces_test.go @@ -0,0 +1,235 @@ +package tui3 + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// ── THE WORDS AFTER `@` MAY HAVE SPACES, ON EVERY SECTION AND EVERY BOX ────── + +// The token walks back over spaces to the nearest `@` that begins 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", 4, "who is kim jong", true}, + {"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}, + } { + 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") + } +} + +// 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) + } + + 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 8283c0a971..ca11c5d41b 100644 --- a/internal/tui3/files.go +++ b/internal/tui3/files.go @@ -211,6 +211,15 @@ func (c *completion) sync(e *editor) { return } c.narrow(at, query, false) + // A MULTI-WORD SEARCH THAT MATCHES NOTHING IS A SENTENCE, and the list + // 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.open = false + } } // narrow is the half sync and [completion.openArg] share: point the list at a @@ -275,14 +284,37 @@ func argToken(value []rune, cursor int) (int, string, bool) { // middle of a word (an email address, a Go doc link) is not one — the run has // to start with it. func atToken(value []rune, cursor int) (int, string, bool) { - start := cursor - for start > 0 && value[start-1] != ' ' && value[start-1] != '\n' { - start-- - } - if start >= cursor || value[start] != '@' { - return 0, "", false + spaces := 0 + for start := cursor - 1; start >= 0; start-- { + switch r := value[start]; { + case r == '\n': + return 0, "", false + case r == ' ': + // THE WORDS AFTER THE `@` MAY HAVE SPACES IN THEM, up to + // [atTokenSpaces]: `@chat:who is` finds `who is kim jong il`, and + // `@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. + return start, string(value[start+1 : cursor]), true + } } - return start, string(value[start+1 : cursor]), true + return 0, "", false +} + +// atTokenSpaces is how many spaces the words after an `@` may hold before they +// 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 } // rank scores every path, team, conversation and task against the query, keeps @@ -391,9 +423,33 @@ 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]) + } + // 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, so + // a sentence typed after a mention does not keep matching by the letters + // it happens to share with a title ([completion.sync] closes on that). + total := 0 + for _, word := range words { + score, ok := wordScore(path, word) + if !ok { + return 0, false + } + total += score } + return total, true +} + +// wordScore is [pathScore] for one word: prefix, then substring, then the +// letters in order. +func wordScore(path, needle string) (int, bool) { lower := strings.ToLower(path) base := lower if cut := strings.LastIndexByte(lower, '/'); cut >= 0 { From 196c8330e3ac0f6f63bdf2dfb1439ffb09d5e469 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:29:55 -0400 Subject: [PATCH 05/10] chat: the words after a chosen mention never reopen the @ list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With spaces allowed in the token, `@side-chat is a chat` walked back to the same `@` and was ranked: `is`, `a` and `chat` all matched `side chat` by their letters, the list reopened over the sentence, and enter put the mention back in its place. Two rules close that: a query that begins with the token this list just inserted, followed by a space, is the person writing on; and only the last word of a needle, the one still being typed, may match by its letters in order — the finished words before it match whole, as a prefix or a substring. The change entry moves from 1720, which another pull request now carries, to 1724; the real number is set when the pull request opens. Co-Authored-By: Claude Fable 5.1 --- ...24-mention-list-recents-and-chat-scope.md} | 2 +- .../manual/chat/conversations-and-teams.md | 12 +++--- internal/manual/chat/keys.md | 10 +++-- internal/tui3/atspaces_test.go | 38 +++++++++++++++++++ internal/tui3/files.go | 33 +++++++++++----- 5 files changed, 75 insertions(+), 20 deletions(-) rename docs/changes/unreleased/{1720-mention-list-recents-and-chat-scope.md => 1724-mention-list-recents-and-chat-scope.md} (99%) diff --git a/docs/changes/unreleased/1720-mention-list-recents-and-chat-scope.md b/docs/changes/unreleased/1724-mention-list-recents-and-chat-scope.md similarity index 99% rename from docs/changes/unreleased/1720-mention-list-recents-and-chat-scope.md rename to docs/changes/unreleased/1724-mention-list-recents-and-chat-scope.md index 689d054ced..489814d44b 100644 --- a/docs/changes/unreleased/1720-mention-list-recents-and-chat-scope.md +++ b/docs/changes/unreleased/1724-mention-list-recents-and-chat-scope.md @@ -1,7 +1,7 @@ --- kind: fixed title: the @ list reads recent conversations on every opening and @chat shows them all -pr: 1720 +pr: 1724 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 again every time the list opens." diff --git a/internal/manual/chat/conversations-and-teams.md b/internal/manual/chat/conversations-and-teams.md index 111bd3071f..94bf8064f8 100644 --- a/internal/manual/chat/conversations-and-teams.md +++ b/internal/manual/chat/conversations-and-teams.md @@ -333,11 +333,13 @@ filters every section that is still showing. Matching is by prefix, then substri then the letters in order — `cloudfl`, `worker` and `cfwd` all find `Cloudflare worker deploy` — against the title, the handle and the slug, never the transcript. The words may have spaces in them, up to three: `@chat:who is` finds `who is kim jong il`, and -`deploy worker` finds it too, because every word must match, in any order. That is the -same rule on every section — `@team:har bor`, `@file:tui3 app` — and on every box: a -conversation's, home's and the new-chat page's. Several words that match nothing close -the list, since that is a sentence after a mention; one word that matches nothing says -`no conversation matches`. +`deploy worker` finds it too, because every word must match, in any order. The word +still being typed may match by its letters in order; the words before it match whole. +That is the same rule on every section — `@team:har bor`, `@file:tui3 app` — and on +every box: a conversation's, home's and the new-chat page's. Several words that match +nothing close the list, since that is a sentence after a mention, and the words typed +after a mention the list has just put in never reopen it; one word that matches nothing +says `no conversation matches`. The bare `@` keeps eight teams and eight conversations beside the tasks and files; `@chat:` keeps every conversation on the list (thirty-two at most) and scrolls. diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index f43d15eebb..0b2b2649bc 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -1548,10 +1548,12 @@ folder as a place; `/folder` is what does that. The token is found by walking back from the caret to the nearest `@` that begins a word, over at most three spaces, never past a newline. So the words after the `@` may have spaces in them — `@chat:who is` finds `who is kim jong il`, `@internal tui3` finds -`internal/tui3/` — and every word must match, in any order. A search of several words -that matches nothing closes the list, because that is a sentence after a mention and -not a search; one word that matches nothing still says `no matches`. An `@` in the -middle of a word — +`internal/tui3/` — and every word must match, in any order. The word still being typed +may match by its letters in order; the words before it are finished and match whole, as +a prefix or a substring. A search of several words that matches nothing closes the list, +because that is a sentence after a mention and not a search, and so do the words you +type after a mention the list has just put in — `@side-chat is a chat` is a sentence; +one word that matches nothing still says `no matches`. 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 diff --git a/internal/tui3/atspaces_test.go b/internal/tui3/atspaces_test.go index 473519820a..ba3db47980 100644 --- a/internal/tui3/atspaces_test.go +++ b/internal/tui3/atspaces_test.go @@ -53,6 +53,10 @@ func TestEveryWordOfTheNeedleMustMatch(t *testing.T) { {"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) @@ -81,6 +85,30 @@ func TestTheListClosesOverASentenceAfterAMention(t *testing.T) { 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 @@ -200,6 +228,16 @@ func TestTheAtListIsTheSameOnEveryBox(t *testing.T) { 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") diff --git a/internal/tui3/files.go b/internal/tui3/files.go index ca11c5d41b..8cf3ec2dbc 100644 --- a/internal/tui3/files.go +++ b/internal/tui3/files.go @@ -206,7 +206,13 @@ 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 { + // AND THE SENTENCE AFTER IT IS NOT A SEARCH EITHER. The token may hold + // spaces now, so `@side-chat is a chat` walks back to the same `@` and + // would be ranked — and `is`, `a` and `chat` all match `side chat` by + // their letters, which reopened the list over the words and let enter put + // the mention back in their place. What this list inserted, followed by a + // space, is the person writing on. + if c.done != "" && at == c.at && (query == c.done || strings.HasPrefix(query, c.done+" ")) { c.open = false return } @@ -428,17 +434,21 @@ func pathScore(path, needle string) (int, bool) { case 0: return len(path), true case 1: - return wordScore(path, words[0]) + 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, so - // a sentence typed after a mention does not keep matching by the letters - // it happens to share with a title ([completion.sync] closes on that). + // 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 _, word := range words { - score, ok := wordScore(path, word) + for i, word := range words { + score, ok := wordScore(path, word, i == len(words)-1) if !ok { return 0, false } @@ -447,9 +457,9 @@ func pathScore(path, needle string) (int, bool) { return total, true } -// wordScore is [pathScore] for one word: prefix, then substring, then the -// letters in order. -func wordScore(path, needle string) (int, bool) { +// 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 { @@ -464,6 +474,9 @@ func wordScore(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 } From 58f39bbe2a36f2475e3e3ba72ae3612098990f89 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:48:11 -0400 Subject: [PATCH 06/10] docs: the change entry carries its pull request number, 1725 Co-Authored-By: Claude Fable 5.1 --- ...hat-scope.md => 1725-mention-list-recents-and-chat-scope.md} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename docs/changes/unreleased/{1724-mention-list-recents-and-chat-scope.md => 1725-mention-list-recents-and-chat-scope.md} (99%) diff --git a/docs/changes/unreleased/1724-mention-list-recents-and-chat-scope.md b/docs/changes/unreleased/1725-mention-list-recents-and-chat-scope.md similarity index 99% rename from docs/changes/unreleased/1724-mention-list-recents-and-chat-scope.md rename to docs/changes/unreleased/1725-mention-list-recents-and-chat-scope.md index 489814d44b..ab6db5c70a 100644 --- a/docs/changes/unreleased/1724-mention-list-recents-and-chat-scope.md +++ b/docs/changes/unreleased/1725-mention-list-recents-and-chat-scope.md @@ -1,7 +1,7 @@ --- kind: fixed title: the @ list reads recent conversations on every opening and @chat shows them all -pr: 1724 +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 again every time the list opens." From 7ff632a5f0f5bc55bbfacd6d47ad01835718c468 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 2 Oct 2026 11:06:16 -0400 Subject: [PATCH 07/10] chat: a bare @ ends at a space, and home's @ list stops crashing A bare @ walked back over up to three spaces, so ordinary prose such as `cc @ara on this` reopened the list on a matching path and enter replaced the sentence with it. Spaces now belong to prefixed searches only (`@chat:who is`, `@team:har bor`, `@file:tui3 app`); a bare @ ends at its first space as it did before, and punctuation after a chosen mention keeps the list closed. Home's @ list could panic with an index out of range (on dev too): opening it replaced the project row that supplied its folder, the loader then switched roots and cleared the paths under rows already ranked against them. Home now captures the folder before the edit, rebuilds rows with every catalog change, ignores walks from an older target, and the painters guard their indexes. A pasted opening on home now copies the same catalogs and starts the same reads as a typed one, and a first spaced search stays open until its catalog has been read. Recent rows and file walks arriving keep the row a person chose, and home's arrows keep the list's own cursor with them. Canonical recent keys are resolved inside the off-loop read, so the update loop does no symlink walks. Home's two composers draw a chosen team in its colour. The manual's @ sections are split, short, and say all of this; the change entry says what moved. Co-Authored-By: Claude Opus 5.5 --- ...725-mention-list-recents-and-chat-scope.md | 11 +- internal/manual/chat/attaching-files.md | 20 +- .../manual/chat/conversations-and-teams.md | 88 ++-- internal/manual/chat/home.md | 17 +- internal/manual/chat/keys.md | 128 +++-- internal/tui3/app.go | 15 +- internal/tui3/atcompletion_regression_test.go | 478 ++++++++++++++++++ internal/tui3/atspaces_test.go | 6 +- internal/tui3/files.go | 133 ++++- internal/tui3/home.go | 14 + internal/tui3/homeat.go | 84 ++- internal/tui3/homephone.go | 1 + internal/tui3/mention.go | 46 +- internal/tui3/pages.go | 1 + internal/tui3/place_home.go | 11 +- 15 files changed, 886 insertions(+), 167 deletions(-) create mode 100644 internal/tui3/atcompletion_regression_test.go 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 index ab6db5c70a..7d2975cee3 100644 --- a/docs/changes/unreleased/1725-mention-list-recents-and-chat-scope.md +++ b/docs/changes/unreleased/1725-mention-list-recents-and-chat-scope.md @@ -1,15 +1,20 @@ --- kind: fixed -title: the @ list reads recent conversations on every opening and @chat shows them all +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 again every time the list opens." - "`@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, then the twenty most recent in this project; older ones and other projects' are reached with `/resume`." + - "The manual did not say which conversations the `@` list holds. It does: every open tab except the one you are in, 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 conversation off." - - "The words after an `@` could not hold a space: `@chat:who is` closed the list that `@chat:who` had opened. The token walks back over up to three spaces, every word must match in any order, on teams, conversations and files alike, and a multi-word search that matches nothing closes the list." + - "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 conversation off." + - "Home's `@` list could crash on dev too: opening replaced the project row that supplied its folder, then the loader changed roots and cleared paths after rows were ranked against them. Home captures the folder before the edit and rebuilds rows with every catalog change; answers from an older target are ignored." + - "A pasted opening on home skipped the catalogs and reads, and an initial spaced search could close before any read started. Keys and pastes now populate the same catalogs and start the same reads, keeping unread searches open." + - "Recent rows or a file walk could move the chosen row before Enter, and home's arrows left its completion cursor behind. Data arrivals now follow the chosen row while it remains offered." + - "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's chosen teams were drawn plain. Punctuation keeps the list closed and both home widths draw the team's colour." --- Santosh's report (2026-09-30): a tab reading `cloudfl…` was on the strip and diff --git a/internal/manual/chat/attaching-files.md b/internal/manual/chat/attaching-files.md index 807619263c..e69f578c4e 100644 --- a/internal/manual/chat/attaching-files.md +++ b/internal/manual/chat/attaching-files.md @@ -561,9 +561,11 @@ 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 conversations on it are the tabs open in this window, except the one you are +The conversations on it are the tabs open in this window, including other projects' +tabs, except the one you are typing in, and then the twenty most recent in this project, read again each time -the list opens. One older than that, or in another project, is not on it. The bare +the list opens, by typing or pasting. An older conversation or one in another +project is offered only if already open here; otherwise 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 conversation off. @@ -580,3 +582,17 @@ 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. + +Typing or pasting starts the same catalog reads, including on home. Several +words with no match close a prefixed search only after its catalog has been read; +before then it stays open with `looking…`. 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 94bf8064f8..f4877fa9f6 100644 --- a/internal/manual/chat/conversations-and-teams.md +++ b/internal/manual/chat/conversations-and-teams.md @@ -316,50 +316,68 @@ each row a coloured dot and the team's name. Conversations are the next section: 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. -**Which conversations are on the list, and which are not.** Every tab open in this -window except the one you are typing in: pointing at the conversation you are in is -not a reference, so it is left off. On the new-chat page (`+` on the strip, or `ctrl+t`) -nothing is left off, because the sentence typed there opens a new conversation — the -one you came from is on the list. Then the twenty most recent conversations in this -project, by when you last spoke in them, read from the disk again each time the list -opens, so a conversation started in another window a minute ago is on it. Over -`--host` those are the far machine's. A conversation older than those twenty, or in -another project, is not on the list: `/resume` is the way to it. - 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. Matching is by prefix, then substring, -then the letters in order — `cloudfl`, `worker` and `cfwd` all find `Cloudflare worker -deploy` — against the title, the handle and the slug, never the transcript. The words -may have spaces in them, up to three: `@chat:who is` finds `who is kim jong il`, and -`deploy worker` finds it too, because every word must match, in any order. The word -still being typed may match by its letters in order; the words before it match whole. -That is the same rule on every section — `@team:har bor`, `@file:tui3 app` — and on -every box: a conversation's, home's and the new-chat page's. Several words that match -nothing close the list, since that is a sentence after a mention, and the words typed -after a mention the list has just put in never reopen it; one word that matches nothing -says `no conversation matches`. - -The bare `@` keeps eight teams and eight conversations beside the tasks and files; -`@chat:` keeps every conversation on the list (thirty-two at most) and scrolls. +types `@team:`, `@chat:` or `@file:`, and the list keeps only that section. -**Home's box has the same list**, the same sections and the same three prefixes, with one -difference: it leaves no conversation off, because a sentence sent from home opens a new -one. The three words on its first row are not buttons there; type the prefix. - -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 the team's colour, on home's box too. +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 tab open in this window, including tabs from other +projects, except the conversation you are typing in. Pointing at that conversation +is not a reference, so it is left off. On the new-chat page (`+` on the strip, or +`ctrl+t`) nothing is left off: the sentence opens a new conversation, so the one +you came from is on the list. + +After the tabs come the twenty most recent conversations in this project, by when +you last spoke in them. They are read again each time the list opens, including a +pasted opening, so a conversation started in another window a minute ago is on it. +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's box has the same sections and three prefixes, with one difference: it +leaves no conversation off, because its sentence opens a new one. The three words +on its first row are not buttons there; type the prefix. Typing or pasting opens +its catalogs and starts the same reads. Rows arriving keep the row you chose +selected while it is still offered. + +## 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 d7a7ca1294..705d8d45e8 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -1483,7 +1483,8 @@ home's column, with the same sections and the same prefixes. Its first row is th 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. `@team:` keeps only teams, `@chat:` only conversations, `@file:` only files, exactly as in a -conversation. The conversations are every tab open in this window — including the one +conversation. Typing or pasting opens the catalogs and starts the same reads. +The conversations are every tab open in this window, including other projects' tabs and the one behind home, since the sentence opens a new conversation — then the twenty most recent in this project, read again each time the list opens. @@ -1498,6 +1499,20 @@ still running the column reads `looking…`; with no match it reads `no matches` 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. +## 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. + +A first pasted search stays open while its catalog is being read. A prefixed +search of several words with no match closes after that read. New rows keep the +chosen row selected while it is still offered. 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 **From inside any conversation, press the space bar twice with an empty message box.** diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index 0b2b2649bc..faed157e6a 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -1524,62 +1524,80 @@ 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. - -The conversations section is the tabs open in this window, except the one you are -typing in, then the twenty most recent conversations in this project, read again on -each opening of the list. The bare `@` keeps eight; `@chat:` keeps them all and -scrolls. - -**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 the nearest `@` that begins a -word, over at most three spaces, never past a newline. So the words after the `@` may -have spaces in them — `@chat:who is` finds `who is kim jong il`, `@internal tui3` finds -`internal/tui3/` — and every word must match, in any order. The word still being typed -may match by its letters in order; the words before it are finished and match whole, as -a prefix or a substring. A search of several words that matches nothing closes the list, -because that is a sentence after a mention and not a search, and so do the words you -type after a mention the list has just put in — `@side-chat is a chat` is a sentence; -one word that matches nothing still says `no matches`. 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, exactly as a +file does. It does not choose that folder as a place; `/folder` does that. + +The prefix words, teams and open tabs arrive from memory. Recent conversations and +the file walk arrive as they are read. Typing or pasting starts the same reads. +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. +New data keeps the chosen row selected while it is still on the list. + +## 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. Then come the twenty most recent in this +project, read again each time the list opens. Older conversations and other +projects' saved conversations require `/resume` unless they are already open here. +Home and the new-chat page leave no 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 folder the next conversation +opens in — the `project:` at the right of the keys row. Moving that target with +`alt+p` or `/project` 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. +Home keeps its walk until its target changes. + +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 @@ -1599,7 +1617,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 5e95899211..a4c0eadc47 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -3885,17 +3885,19 @@ 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 } + chosen := a.comp.selectionKey(a.comp.selLine()) a.comp.all, a.comp.loaded, a.comp.loading = msg.paths, true, false a.fillMentions() - a.comp.rank() + a.comp.refresh(&a.input) + a.comp.restoreSelection(chosen) a.touch() return a, nil case mentionRecentsMsg: - a.mentionRecentsLoaded(msg.rows) + a.mentionRecentsLoaded(msg.rows, msg.keys...) return a, nil case tasksLoadedMsg: @@ -9016,6 +9018,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, root := a.home.comp.open, "" + if box == &a.home.box { + root = a.prepareHomeCompletion() + } wasEmpty := len(box.value) == 0 if box == &a.home.box { a.home.projectPaste.path = "" @@ -9031,6 +9037,9 @@ func (a *app) paste(text string) tea.Cmd { } a.dropLanded(box) a.touch() + if box == &a.home.box { + return a.syncHomeCompletion(was, root) + } return nil } // A DROPPED PICTURE IS A PICTURE. A terminal writes a drag-and-drop into the diff --git a/internal/tui3/atcompletion_regression_test.go b/internal/tui3/atcompletion_regression_test.go new file mode 100644 index 0000000000..4c946340b7 --- /dev/null +++ b/internal/tui3/atcompletion_regression_test.go @@ -0,0 +1,478 @@ +package tui3 + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "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 != other { + t.Errorf("opening the list changed its root from the selected project to %q", a.home.walked) + } + 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 + 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 engine conversation and the shell carrying its unsent words have +// different transcripts, even when the shell's tab borrows those words as its title. +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 draft, recent mentionChat + for _, chat := range a.home.comp.chatHits { + if chat.title == "see node_modules/@types/node is old" { + draft = chat + } + if chat.title == "user asks who kim jong il is" { + recent = chat + } + } + if draft.key == "" || recent.key == "" { + t.Fatalf("missing moved fixture rows: %+v", a.home.comp.chatHits) + } + if draft.key == recent.key || draft.file == recent.file { + t.Fatalf("draft and recent are the same transcript: %+v %+v", draft, recent) + } + if draft.file != filepath.Join(where, "next", "transcript.jsonl") { + t.Fatalf("draft tab does not name the new shell: %+v", draft) + } + for _, e := range a.entries { + if e.kind == entryUser { + t.Fatal("the shell already has a sent message") + } + } + t.Logf("draft key=%q file=%q; moved key=%q file=%q; shell has no sent messages", draft.key, draft.file, 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") + } +} diff --git a/internal/tui3/atspaces_test.go b/internal/tui3/atspaces_test.go index ba3db47980..8b196c6b55 100644 --- a/internal/tui3/atspaces_test.go +++ b/internal/tui3/atspaces_test.go @@ -8,9 +8,9 @@ import ( "time" ) -// ── THE WORDS AFTER `@` MAY HAVE SPACES, ON EVERY SECTION AND EVERY BOX ────── +// ── PREFIXED SEARCHES MAY HAVE SPACES, ON EVERY BOX ────── -// The token walks back over spaces to the nearest `@` that begins a word, and +// 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 { @@ -20,7 +20,7 @@ func TestAtTokenHoldsSpaces(t *testing.T) { ok bool }{ {"@chat:who is", 0, "chat:who is", true}, - {"see @who is kim jong", 4, "who is kim jong", 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}, diff --git a/internal/tui3/files.go b/internal/tui3/files.go index 8cf3ec2dbc..16202e1bac 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" @@ -136,6 +137,8 @@ type completion struct { // 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 // 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 @@ -206,28 +209,37 @@ 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. - // AND THE SENTENCE AFTER IT IS NOT A SEARCH EITHER. The token may hold - // spaces now, so `@side-chat is a chat` walks back to the same `@` and - // would be ranked — and `is`, `a` and `chat` all match `side chat` by - // their letters, which reopened the list over the words and let enter put - // the mention back in their place. What this list inserted, followed by a - // space, is the person writing on. - if c.done != "" && at == c.at && (query == c.done || strings.HasPrefix(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 IS A SENTENCE, and the list + // 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() { + if strings.Contains(strings.TrimSpace(query), " ") && !c.anyHits() && c.catalogsReady() { c.open = false } } +// 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. @@ -285,10 +297,10 @@ 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) { spaces := 0 for start := cursor - 1; start >= 0; start-- { @@ -296,9 +308,9 @@ func atToken(value []rune, cursor int) (int, string, bool) { case r == '\n': return 0, "", false case r == ' ': - // THE WORDS AFTER THE `@` MAY HAVE SPACES IN THEM, up to + // A PREFIXED SEARCH MAY HAVE SPACES IN IT, up to // [atTokenSpaces]: `@chat:who is` finds `who is kim jong il`, and - // `@internal tui3` finds internal/tui3. Past that many it is a + // `@file:internal tui3` finds internal/tui3. Past that many it is a // sentence, not a search. spaces++ if spaces > atTokenSpaces { @@ -307,13 +319,17 @@ func atToken(value []rune, cursor int) (int, string, bool) { 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. - return start, string(value[start+1 : cursor]), true + 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 the words after an `@` may hold before they +// 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 @@ -323,6 +339,63 @@ 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 "" + } + 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 leaves the ordinary clamped cursor in force. +func (c *completion) restoreSelection(chosen string) { + if chosen == "" { + return + } + for cursor, at := range c.sel { + if c.selectionKey(at) == chosen { + c.cursor = cursor + break + } + } + c.follow(c.rowsWanted()) +} + // rank scores every path, team, conversation and task against the query, keeps // what matched, and lays them out as one list. func (c *completion) rank() { @@ -389,9 +462,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 { @@ -616,22 +692,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 "" @@ -681,6 +760,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 5f4227574e..bc9d356d04 100644 --- a/internal/tui3/home.go +++ b/internal/tui3/home.go @@ -2402,6 +2402,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 diff --git a/internal/tui3/homeat.go b/internal/tui3/homeat.go index cff316a860..38129e258c 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" ) @@ -128,11 +129,11 @@ func (h *homeView) completionWords(line homeLine, pal palette) (label, note stri } cl := c.lines[line.comp] switch { - case cl.team >= 0: + 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: + case cl.chat >= 0 && cl.chat < len(c.chatHits): return mentionChatLabel(c.chatHits[cl.chat]), c.lineNote(line.comp), true - case cl.file >= 0: + case cl.file >= 0 && cl.file < len(c.all): return c.all[cl.file], c.lineNote(line.comp), true } return "", "", false @@ -144,53 +145,90 @@ func (h *homeView) completionPath(line homeLine) (string, bool) { if line.comp < 0 || line.comp >= len(c.lines) || c.lines[line.comp].file < 0 { return "", false } - return c.all[c.lines[line.comp].file], true + 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 on the key -// that reaches home's box and never from a frame, and it leaves no +// [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.comp.recentsLoaded || len(a.comp.recents) > 0 || a.recentSessions == nil } -// loadHomeFiles walks the target folder for the list, once per target: a -// walk already done or already running is left alone, and a target that moved -// since the last walk starts a fresh one. It is asked after every key on home -// (place_home.go), and answers nil on every key that did not open the list. -func (a *app) loadHomeFiles() tea.Cmd { - h := &a.home - if !h.comp.open { - return nil +// homeCompletionRoot captures the target before the list replaces the project +// row that supplied it. While the list is up, its own rows have no project address. +func (a *app) homeCompletionRoot() string { + if a.home.comp.open && strings.TrimSpace(a.target.where) == "" && a.home.walked != "" { + return a.home.walked } root := a.targetWhere() if root == "" { root = a.pathRoot() } - if root == "" { - return nil - } + return root +} + +// prepareHomeCompletion captures the project's address and aligns its catalog +// before the edit ranks it. A pasted search must not run against an old target. +func (a *app) prepareHomeCompletion() string { + root := a.homeCompletionRoot() + a.fillHomeMentions() + h := &a.home if h.walked != root { h.comp.all, h.comp.loaded, h.comp.loading = nil, false, false h.walked = root + h.comp.rank() + if h.comp.open { + h.build() + } + } + return root +} + +// syncHomeCompletion starts the same reads for a typed or pasted opening. +// The root was captured before the edit, while home's project row still existed. +func (a *app) syncHomeCompletion(was bool, root string) tea.Cmd { + files := a.loadHomeFiles(root) + var recents tea.Cmd + if a.home.comp.open && !was { + recents = a.loadMentionRecents() + } + return tea.Batch(files, recents) +} + +// loadHomeFiles walks the captured target 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 } 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} } } // 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 + } + chosen := h.comp.selectionKey(h.cursor) h.comp.all, h.comp.loaded, h.comp.loading = paths, true, false h.comp.rank() + h.comp.restoreSelection(chosen) h.build() a.touch() } @@ -206,12 +244,12 @@ func (a *app) homeComplete(line homeLine) tea.Cmd { if line.comp >= 0 && line.comp < len(c.lines) { cl := c.lines[line.comp] switch { - case cl.team >= 0: + 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: + case cl.chat >= 0 && cl.chat < len(c.chatHits): completeChatIn(&h.box, c, c.chatHits[cl.chat]) h.build() a.touch() diff --git a/internal/tui3/homephone.go b/internal/tui3/homephone.go index f504324904..bb8a7fbb1e 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 { diff --git a/internal/tui3/mention.go b/internal/tui3/mention.go index 890e99a6aa..2f84a8f662 100644 --- a/internal/tui3/mention.go +++ b/internal/tui3/mention.go @@ -1,6 +1,7 @@ package tui3 import ( + "path/filepath" "strings" tea "charm.land/bubbletea/v2" @@ -238,6 +239,7 @@ func mentionToken(chat mentionChat) string { func (a *app) fillMentions() { a.comp.teams = a.mentionTeams() a.comp.chats = a.mentionChats() + a.comp.recentsLoaded = a.comp.recentsLoaded || len(a.comp.recents) > 0 || a.recentSessions == nil } func (a *app) mentionTeams() []mentionTeam { @@ -337,7 +339,10 @@ 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 +} // 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. @@ -353,29 +358,52 @@ func (a *app) loadMentionRecents() tea.Cmd { return nil } a.comp.recentsHeld = true - read := a.recentSessions + 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} } } -func (a *app) mentionRecentsLoaded(rows []Session) { +// 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 = false + chosen := a.comp.selectionKey(a.comp.selLine()) + homeChosen := a.home.comp.selectionKey(a.home.cursor) + a.comp.recentsHeld, a.comp.recentsLoaded = false, true 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) // 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 := a.convKey(file) + key := "" + if i < len(keys) { + key = keys[i] + } else if file != "" { + key = filepath.Clean(file) + } if key == "" || seen[key] { continue } @@ -395,12 +423,14 @@ func (a *app) mentionRecentsLoaded(rows []Session) { } if a.comp.open { a.fillMentions() - a.comp.rank() + a.comp.refresh(&a.input) + a.comp.restoreSelection(chosen) } // 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.comp.restoreSelection(homeChosen) a.home.build() } a.touch() diff --git a/internal/tui3/pages.go b/internal/tui3/pages.go index bb3ef7b500..1e32e167f4 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 4f835da91c..fcff002a72 100644 --- a/internal/tui3/place_home.go +++ b/internal/tui3/place_home.go @@ -551,17 +551,10 @@ func (placeHome) wheel(a *app, delta int) (tea.Cmd, bool) { return nil, false } 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]). - a.fillHomeMentions() was := a.home.comp.open + root := a.prepareHomeCompletion() answered := a.homeKey(msg) - // AND THE `@` LIST'S WALK STARTS THE KEY THAT OPENED IT (homeat.go), and - // so does the read of the recent conversations, on the opening and not on - // the letters after it (mention.go's [app.loadMentionRecents]). - var recents tea.Cmd - if a.home.comp.open && !was { - recents = a.loadMentionRecents() - } - return tea.Batch(answered, a.loadHomeFiles(), recents, a.refreshHomeCard(a.now())) + return tea.Batch(answered, a.syncHomeCompletion(was, root), a.refreshHomeCard(a.now())) } // owns is the two layers of home that take the WHOLE keyboard, `tab` included, From 8576f65dc3ac07fb3724ee4c10c111853b913ebe Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 2 Oct 2026 12:22:15 -0400 Subject: [PATCH 08/10] home: the @ list walks the folder its sentence opens in The previous commit captured home's @ file root from the row the cursor stood on before the person typed, so a list opened over another project's conversation offered that project's files while the foot, and the conversation the sentence then started, used this window's folder. The list now walks the pinned target or this window's file root (this machine's folder over --host, like a conversation's own list); the folder a home sentence opens in is unchanged from dev. Catalogs and rows still change together when the target moves, a walk from an older folder is ignored, and keys pressed with the list closed keep a completed walk. Every opening, typed or pasted, reads the recent list again, and a prefixed chat search waits for that answer instead of closing on the previous catalog. A chosen team is painted from the current team catalog, which is built only for a row holding a team mark, so a plain draft row allocates nothing. A conversation with no name of its own and nothing sent (a tab named only by its unsent draft) is not offered as a mention. The manual and the change entry say all of this. Co-Authored-By: Claude Opus 5.5 --- ...725-mention-list-recents-and-chat-scope.md | 13 +- internal/manual/chat/attaching-files.md | 32 +- .../manual/chat/conversations-and-teams.md | 23 +- internal/manual/chat/home.md | 30 +- internal/manual/chat/keys.md | 22 +- internal/tui3/app.go | 12 +- internal/tui3/atcompletion_regression_test.go | 364 +++++++++++++++++- internal/tui3/files.go | 8 + internal/tui3/homeat.go | 91 +++-- internal/tui3/mention.go | 58 ++- internal/tui3/place_home.go | 6 +- 11 files changed, 553 insertions(+), 106 deletions(-) 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 index 7d2975cee3..04fbd6575f 100644 --- a/docs/changes/unreleased/1725-mention-list-recents-and-chat-scope.md +++ b/docs/changes/unreleased/1725-mention-list-recents-and-chat-scope.md @@ -6,15 +6,16 @@ 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 again every time the list opens." - "`@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, 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 conversation off." + - "The manual did not say which conversations the `@` list holds. It does: every named or sent open conversation except the one you are in, 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 conversation off." - - "Home's `@` list could crash on dev too: opening replaced the project row that supplied its folder, then the loader changed roots and cleared paths after rows were ranked against them. Home captures the folder before the edit and rebuilds rows with every catalog change; answers from an older target are ignored." - - "A pasted opening on home skipped the catalogs and reads, and an initial spaced search could close before any read started. Keys and pastes now populate the same catalogs and start the same reads, keeping unread searches open." + - "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. Every typed or pasted opening now reads recents again, and prefixed multi-word chat searches wait for that answer; letters within an open list start no new read." - "Recent rows or a file walk could move the chosen row before Enter, and home's arrows left its completion cursor behind. Data arrivals now follow the chosen row while it remains offered." - "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's chosen teams were drawn plain. Punctuation keeps the list closed and both home widths draw the team's colour." + - "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 a conversation with a name of its own or something sent is offered; its tab is unchanged." --- Santosh's report (2026-09-30): a tab reading `cloudfl…` was on the strip and diff --git a/internal/manual/chat/attaching-files.md b/internal/manual/chat/attaching-files.md index e69f578c4e..e762179a8d 100644 --- a/internal/manual/chat/attaching-files.md +++ b/internal/manual/chat/attaching-files.md @@ -559,21 +559,24 @@ 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, and then the twenty most recent in this project, read again each time -the list opens, by typing or pasting. An older conversation or one in another -project is offered only if already open here; otherwise use `/resume`. The bare +tabs, except the one you are typing in. An unnamed conversation with nothing sent +is absent even if its unsent draft names a tab. Then come the twenty most recent +in this project, read again each time the list opens, by typing or pasting. 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 conversation off. +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 team opens the teams page with it selected. A press on the conversation opens it. Over @@ -592,7 +595,8 @@ 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. -Typing or pasting starts the same catalog reads, including on home. Several -words with no match close a prefixed search only after its catalog has been read; -before then it stays open with `looking…`. A space or punctuation after a chosen -mention keeps the list closed so you can write on. +Every opening starts a fresh read of the recent list, by typing or pasting, +including on home and after an earlier read. Further letters in an open list +start no new read. Several words with no match close a prefixed search only after its catalog has been read; +before that fresh read answers it stays open with `looking…`. 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 f4877fa9f6..f52eeebabb 100644 --- a/internal/manual/chat/conversations-and-teams.md +++ b/internal/manual/chat/conversations-and-teams.md @@ -320,7 +320,8 @@ The first row is the words **team**, **chat** and **file**. Each is a button wit 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. -Choosing a team inserts `●harbor` in the team's colour, on home's box too. +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 @@ -335,14 +336,17 @@ does not message it and does not wake it. Your transcript keeps the words you ty ## Which conversations the @ list offers, and why the one I am in is missing The `@` list offers every tab open in this window, including tabs from other -projects, except the conversation you are typing in. Pointing at that conversation -is not a reference, so it is left off. On the new-chat page (`+` on the strip, or -`ctrl+t`) nothing is left off: the sentence opens a new conversation, so the one -you came from is on the list. +projects, except the conversation you are typing in: pointing at it is not a +reference. An unnamed conversation with nothing sent is absent, even when its +unsent draft gives its tab a name. On the new-chat page (`+` on the strip, or +`ctrl+t`) no eligible conversation is left off: the sentence opens a new +conversation, so the one you came from is on the list. After the tabs come the twenty most recent conversations in this project, by when you last spoke in them. They are read again each time the list opens, including a -pasted opening, so a conversation started in another window a minute ago is on it. +pasted opening. Until that fresh read answers, a prefixed multi-word chat search +stays up with `looking…`, even after an earlier read. Typing further letters into +an already-open list starts no new read. 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. @@ -351,11 +355,14 @@ The bare `@` keeps eight teams and eight conversations beside the tasks and file `@chat:` and `@team:` keep up to thirty-two rows in their section and scroll. Home's box has the same sections and three prefixes, with one difference: it -leaves no conversation off, because its sentence opens a new one. The three words -on its first row are not buttons there; type the prefix. Typing or pasting opens +leaves no eligible conversation off, because its sentence opens a new one. The +three words on its first row are not buttons there; type the prefix. Typing or pasting opens its catalogs and starts the same reads. Rows arriving keep the row you chose selected while it is still offered. +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 diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index 705d8d45e8..1ad6918371 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -1480,13 +1480,15 @@ tab that turns them off, are on the *hints and tips* page. 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 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. `@team:` +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. Typing or pasting opens the catalogs and starts the same reads. -The conversations are every tab open in this window, including other projects' tabs and the one -behind home, since the sentence opens a new conversation — then the twenty most recent -in this project, read again each time the list opens. +The conversations are named or sent conversations open in this window, including other +projects and the one behind home — then the twenty most recent in this project. An unnamed +conversation with nothing sent is absent even if its unsent draft names a tab. Each time +the list appears, including a paste, it reads the recent list again. `↑`/`↓` 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 @@ -1499,6 +1501,21 @@ still running the column reads `looking…`; with no match it reads `no matches` 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 @@ -1508,7 +1525,8 @@ token. Only prefixed searches hold spaces, up to three and never across a newlin match every word in any order; finished words match whole, and only the final word may match by letters in order. -A first pasted search stays open while its catalog is being read. A prefixed +Every opening reads the recent list again, including a pasted search after an earlier read. +A prefixed chat search stays open with `looking…` until that fresh read answers. A prefixed search of several words with no match closes after that read. New rows keep the chosen row selected while it is still offered. After choosing, a space or punctuation such as a comma keeps the list closed so you can write on. diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index faed157e6a..18aa9aed13 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -1540,7 +1540,9 @@ picture's `img` note. Choosing one puts its path into the sentence, exactly as a file does. It does not choose that folder as a place; `/folder` does that. The prefix words, teams and open tabs arrive from memory. Recent conversations and -the file walk arrive as they are read. Typing or pasting starts the same reads. +the file walk arrive as they are read. Whenever the list appears, by typing or +pasting, it reads the recent list again. A prefixed multi-word conversation search waits with +`looking…` until that fresh read answers, even after an earlier read. 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. New data keeps the chosen row selected while it is still on the list. @@ -1570,10 +1572,11 @@ 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. Then come the twenty most recent in this +tabs, except the one you are typing in. An unnamed conversation with nothing sent +is absent even if its unsent draft names a tab. Then come the twenty most recent in this project, read again each time the list opens. Older conversations and other projects' saved conversations require `/resume` unless they are already open here. -Home and the new-chat page leave no conversation off: their sentences start a +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:` @@ -1584,16 +1587,21 @@ 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 folder the next conversation -opens in — the `project:` at the right of the keys row. Moving that target with -`alt+p` or `/project` walks again. Home offers files and folders, never tasks: +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. -Home keeps its walk until its target changes. +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. diff --git a/internal/tui3/app.go b/internal/tui3/app.go index a4c0eadc47..b7b2345a44 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -9018,9 +9018,9 @@ 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, root := a.home.comp.open, "" + was := a.home.comp.open if box == &a.home.box { - root = a.prepareHomeCompletion() + a.prepareHomeCompletion(text) } wasEmpty := len(box.value) == 0 if box == &a.home.box { @@ -9038,7 +9038,7 @@ func (a *app) paste(text string) tea.Cmd { a.dropLanded(box) a.touch() if box == &a.home.box { - return a.syncHomeCompletion(was, root) + return a.syncHomeCompletion(was) } return nil } @@ -9226,8 +9226,10 @@ func (a *app) syncLists() tea.Cmd { was := a.comp.open 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). + if (a.comp.open || a.comp.opened) && !was { + a.comp.opened = false + // Recognizing an opening proves that `@` has been found (notice.go), + // even when its ready section closes before a frame. a.noticeEvent(eventAtOpened) // Both halves of the list are asked for at the same moment, and neither // waits for the other: the index is one small file and lands first, the diff --git a/internal/tui3/atcompletion_regression_test.go b/internal/tui3/atcompletion_regression_test.go index 4c946340b7..d68503f550 100644 --- a/internal/tui3/atcompletion_regression_test.go +++ b/internal/tui3/atcompletion_regression_test.go @@ -1,6 +1,7 @@ package tui3 import ( + "fmt" "os" "os/exec" "path/filepath" @@ -124,8 +125,8 @@ func TestHomeAtTargetChangeNeverLeavesStaleFileRows(t *testing.T) { } _, cmd := a.Update(key("@")) c := &a.home.comp - if a.home.walked != other { - t.Errorf("opening the list changed its root from the selected project to %q", a.home.walked) + 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) { @@ -339,6 +340,7 @@ func TestHomeChosenTeamUsesItsColourAtWideAndPhoneWidths(t *testing.T) { 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) @@ -374,8 +376,8 @@ func TestMentionManualSectionsAreShortAndExplainPrefixedSpaces(t *testing.T) { } } -// The moved engine conversation and the shell carrying its unsent words have -// different transcripts, even when the shell's tab borrows those words as its title. +// 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)) @@ -406,30 +408,27 @@ func TestMovedDraftTabIsADifferentConversationFromTheRecentRow(t *testing.T) { 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 draft, recent mentionChat + var recent mentionChat for _, chat := range a.home.comp.chatHits { if chat.title == "see node_modules/@types/node is old" { - draft = chat + t.Errorf("unsent shell is offered as a conversation: %+v", chat) } if chat.title == "user asks who kim jong il is" { recent = chat } } - if draft.key == "" || recent.key == "" { - t.Fatalf("missing moved fixture rows: %+v", a.home.comp.chatHits) + if recent.key == "" || recent.file != mine { + t.Fatalf("the moved conversation is missing: %+v", a.home.comp.chatHits) } - if draft.key == recent.key || draft.file == recent.file { - t.Fatalf("draft and recent are the same transcript: %+v %+v", draft, recent) - } - if draft.file != filepath.Join(where, "next", "transcript.jsonl") { - t.Fatalf("draft tab does not name the new shell: %+v", draft) + 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("draft key=%q file=%q; moved key=%q file=%q; shell has no sent messages", draft.key, draft.file, recent.key, recent.file) + 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 @@ -476,3 +475,340 @@ func TestHomeAtIgnoresAFileWalkFromItsPreviousTarget(t *testing.T) { 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. +func TestAtListOmitsUnsentShellsOnEveryBox(t *testing.T) { + for _, b := range atBoxes { + for _, kind := range []string{"draft", "sent", "named"} { + 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 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 := 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) + } +} diff --git a/internal/tui3/files.go b/internal/tui3/files.go index 16202e1bac..c72fb71937 100644 --- a/internal/tui3/files.go +++ b/internal/tui3/files.go @@ -78,6 +78,9 @@ var skipDirs = map[string]bool{ // keeps its walk across closes: the second @ of a session opens instantly. type completion struct { open bool + // opened records a new @ search even when a ready unmatched section + // immediately closes it, because every opening still reads recents. + opened bool // at is the rune index of the '@' in the draft, and query what follows it. at int query string @@ -139,6 +142,9 @@ type completion struct { 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 // 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 @@ -175,6 +181,7 @@ func deadLine() compLine { // draft is still what it is about — but it answers to [argToken] instead, and // it stays open while the person types a path with spaces in it. func (c *completion) sync(e *editor) { + c.opened = false if strings.HasPrefix(strings.TrimSpace(e.String()), "!") { c.close() return @@ -218,6 +225,7 @@ func (c *completion) sync(e *editor) { return } } + c.opened = !c.open 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 diff --git a/internal/tui3/homeat.go b/internal/tui3/homeat.go index 38129e258c..faa344eb50 100644 --- a/internal/tui3/homeat.go +++ b/internal/tui3/homeat.go @@ -159,57 +159,75 @@ func (h *homeView) completionPath(line homeLine) (string, bool) { func (a *app) fillHomeMentions() { a.home.comp.teams = a.mentionTeams() a.home.comp.chats = a.mentionChatsExcept("") - a.home.comp.recentsLoaded = a.comp.recentsLoaded || len(a.comp.recents) > 0 || a.recentSessions == nil + a.home.comp.recentsLoaded = a.mentionRecentsReady(a.home.comp.open) } -// homeCompletionRoot captures the target before the list replaces the project -// row that supplied it. While the list is up, its own rows have no project address. +// 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 a.home.comp.open && strings.TrimSpace(a.target.where) == "" && a.home.walked != "" { - return a.home.walked + if pinned := strings.TrimSpace(a.target.where); pinned != "" { + return pinned } - root := a.targetWhere() - if root == "" { - root = a.pathRoot() - } - return root + return a.pathRoot() } -// prepareHomeCompletion captures the project's address and aligns its catalog -// before the edit ranks it. A pasted search must not run against an old target. -func (a *app) prepareHomeCompletion() string { - root := a.homeCompletionRoot() - a.fillHomeMentions() +// 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 - if h.walked != root { - h.comp.all, h.comp.loaded, h.comp.loading = nil, false, false - h.walked = root - h.comp.rank() - if h.comp.open { - h.build() + h.comp.opened = false + h.comp.recentsLoaded = a.mentionRecentsReady(h.comp.open) + if !h.comp.open { + 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 != "" && 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 + } } } - return root + a.fillHomeMentions() + a.alignHomeFiles(a.homeCompletionRoot()) } -// syncHomeCompletion starts the same reads for a typed or pasted opening. -// The root was captured before the edit, while home's project row still existed. -func (a *app) syncHomeCompletion(was bool, root string) tea.Cmd { - files := a.loadHomeFiles(root) +// 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(was bool) tea.Cmd { + h := &a.home + root := a.homeCompletionRoot() var recents tea.Cmd - if a.home.comp.open && !was { + if (h.comp.open || h.comp.opened) && !was { recents = a.loadMentionRecents() + a.fillHomeMentions() + a.alignHomeFiles(root) + h.build() + h.comp.opened = false } - return tea.Batch(files, recents) + return tea.Batch(a.loadHomeFiles(root), recents) } -// loadHomeFiles walks the captured target once, off the loop. Catalogs and +// 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 } @@ -218,6 +236,21 @@ func (a *app) loadHomeFiles(root string) tea.Cmd { 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, root string) { diff --git a/internal/tui3/mention.go b/internal/tui3/mention.go index 2f84a8f662..d3c13f7697 100644 --- a/internal/tui3/mention.go +++ b/internal/tui3/mention.go @@ -5,7 +5,6 @@ import ( "strings" tea "charm.land/bubbletea/v2" - "github.com/charmbracelet/x/ansi" "github.com/Agent-Field/codeaf/internal/session" ) @@ -239,7 +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.comp.recentsLoaded || len(a.comp.recents) > 0 || a.recentSessions == nil + a.comp.recentsLoaded = a.mentionRecentsReady(a.comp.open) +} + +// mentionRecentsReady keeps an opening's cached rows visible while treating +// its fresh read as pending. Further edits wait for that same read to finish. +func (a *app) mentionRecentsReady(open bool) bool { + return a.recentSessions == nil || !a.comp.recentsPending && (open || a.comp.recentsHeld) && (a.comp.recentsLoaded || len(a.comp.recents) > 0) } func (a *app) mentionTeams() []mentionTeam { @@ -286,7 +291,7 @@ 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 @@ -303,6 +308,33 @@ func (a *app) mentionChatsExcept(front string) []mentionChat { return out } +// mentionableTab distinguishes a conversation's own name or sent opening +// from the unsent words the strip may borrow. This list never reads a transcript. +func (a *app) mentionableTab(tab chatTab) bool { + named := func(title string) bool { + title = strings.TrimSpace(title) + return title != "" && title != unnamedConversationWord + } + if tab.key == a.frontTabKey() { + if named(a.title) || strings.TrimSpace(a.openingPrompt) != "" { + return true + } + for _, e := range a.entries { + if e.kind == entryUser { + return true + } + } + return false + } + if held := a.behind[tab.key]; held != nil { + if held.conv.Agent != nil && named(held.conv.Agent.Title()) { + return true + } + return held.side != nil && (named(held.side.title) || strings.TrimSpace(held.side.openingPrompt) != "") + } + return named(tab.full) +} + func (a *app) mentionFromTab(tab chatTab, open bool) mentionChat { title := strings.TrimSpace(tab.full) if title == "" { @@ -357,7 +389,7 @@ func (a *app) loadMentionRecents() tea.Cmd { if a.comp.recentsHeld || a.recentSessions == nil { return nil } - a.comp.recentsHeld = true + a.comp.recentsHeld, a.comp.recentsLoaded, a.comp.recentsPending = true, false, true read, hosted := a.recentSessions, a.hosted() return func() tea.Msg { list := read() @@ -388,7 +420,7 @@ func (a *app) mentionRecentsLoaded(rows []Session, keys ...string) { // The read has landed, so the next opening of the list may ask again. chosen := a.comp.selectionKey(a.comp.selLine()) homeChosen := a.home.comp.selectionKey(a.home.cursor) - a.comp.recentsHeld, a.comp.recentsLoaded = false, true + a.comp.recentsHeld, a.comp.recentsLoaded, a.comp.recentsPending = false, true, false a.comp.recents = a.comp.recents[:0] seen := map[string]bool{} for i, row := range rows { @@ -583,18 +615,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/place_home.go b/internal/tui3/place_home.go index fcff002a72..c0629f33b4 100644 --- a/internal/tui3/place_home.go +++ b/internal/tui3/place_home.go @@ -552,9 +552,9 @@ 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 - root := a.prepareHomeCompletion() + a.prepareHomeCompletion(msg.Text) answered := a.homeKey(msg) - return tea.Batch(answered, a.syncHomeCompletion(was, root), 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, @@ -582,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() From 1777c5fa6b51d293e362e017872abee5253b7a4b Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 2 Oct 2026 13:26:01 -0400 Subject: [PATCH 09/10] chat: one recent read per @ opening, and home re-reads after it empties An opening is now a new @ token, or the list coming back after the person dismissed it with esc. Letters typed into the same token, caret moves within it and the no-match rule hiding the list never start another read or another opening notice; before this, typing `@chat:no such words` read the recent list ten times. A later token starts its own read and an older answer cannot replace its rows. Home's box emptied by backspace drew the grid without syncing its @ list, so the list stayed "open" and the next @ was not an opening: a conversation started in another window was never offered on home (Ara's original head did the same). An empty home box now closes its list, and the next @ reads the recent list again. A data arrival keeps the row the person chose with the arrows while it is still offered; when nobody chose, the selection follows the best match, so a fresh read that puts the exact title on top selects it. Only the window's own unsent conversation is left off the list; held, restored and side tabs are offered as before, so a first queued follow-up stays mentionable. The manual and the change entry say so. Co-Authored-By: Claude Opus 5.5 --- ...725-mention-list-recents-and-chat-scope.md | 11 +- internal/manual/chat/attaching-files.md | 31 +- .../manual/chat/conversations-and-teams.md | 31 +- internal/manual/chat/home.md | 27 +- internal/manual/chat/keys.md | 19 +- internal/tui3/app.go | 41 ++- internal/tui3/atcompletion_regression_test.go | 311 +++++++++++++++++- internal/tui3/files.go | 96 ++++-- internal/tui3/home.go | 3 + internal/tui3/homeat.go | 22 +- internal/tui3/mention.go | 60 ++-- 11 files changed, 522 insertions(+), 130 deletions(-) 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 index 04fbd6575f..dfc0c79c8c 100644 --- a/docs/changes/unreleased/1725-mention-list-recents-and-chat-scope.md +++ b/docs/changes/unreleased/1725-mention-list-recents-and-chat-scope.md @@ -4,18 +4,19 @@ title: the @ list finds recent conversations on every box without taking over pr 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 again every time the list opens." + - "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: a new `@` token, typed or pasted, or the list returning on the next letter after `esc`." - "`@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 named or sent open conversation except the one you are in, 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." + - "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. Every typed or pasted opening now reads recents again, and prefixed multi-word chat searches wait for that answer; letters within an open list start no new read." - - "Recent rows or a file walk could move the chosen row before Enter, and home's arrows left its completion cursor behind. Data arrivals now follow the chosen row while it remains offered." + - "A pasted opening could skip the fresh recent read when an earlier catalog matched nothing. A new token now reads recents exactly once, even when its unmatched display closes; 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; 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 `@` starts a fresh read." - "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 a conversation with a name of its own or something sent is offered; its tab is unchanged." + - "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 diff --git a/internal/manual/chat/attaching-files.md b/internal/manual/chat/attaching-files.md index 87edfeb6e8..7bbf9aec00 100644 --- a/internal/manual/chat/attaching-files.md +++ b/internal/manual/chat/attaching-files.md @@ -562,10 +562,12 @@ filters every section at once. `@file:` keeps only files, `@team:` only teams, 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. An unnamed conversation with nothing sent -is absent even if its unsent draft names a tab. Then come the twenty most recent -in this project, read again each time the list opens, by typing or pasting. For -older or other-project conversations not open here, use `/resume`. The bare +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: a new `@` token or the list +returning on the next letter after `esc`. 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: ` @@ -578,7 +580,10 @@ 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 @@ -595,8 +600,14 @@ 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. -Every opening starts a fresh read of the recent list, by typing or pasting, -including on home and after an earlier read. Further letters in an open list -start no new read. Several words with no match close a prefixed search only after its catalog has been read; -before that fresh read answers it stays open with `looking…`. A space or -punctuation after a chosen mention keeps the list closed so you can write on. +Recents are read once per opening: a new token appearing or starting at a different +`@`, or the next letter bringing the list back after `esc`. Letters and caret moves +in an existing opening, and automatic unmatched closes, start no new read. 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 f52eeebabb..d93bc21206 100644 --- a/internal/manual/chat/conversations-and-teams.md +++ b/internal/manual/chat/conversations-and-teams.md @@ -335,18 +335,20 @@ does not message it and does not wake it. Your transcript keeps the words you ty ## Which conversations the @ list offers, and why the one I am in is missing -The `@` list offers every tab open in this window, including tabs from other -projects, except the conversation you are typing in: pointing at it is not a -reference. An unnamed conversation with nothing sent is absent, even when its -unsent draft gives its tab a name. On the new-chat page (`+` on the strip, or -`ctrl+t`) no eligible conversation is left off: the sentence opens a new -conversation, so the one you came from is on the list. +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, by when -you last spoke in them. They are read again each time the list opens, including a -pasted opening. Until that fresh read answers, a prefixed multi-word chat search -stays up with `looking…`, even after an earlier read. Typing further letters into -an already-open list starts no new read. +you last spoke in them. They are read once per opening: a new token appearing or +starting at a different `@`, typed or pasted, or the next letter bringing the list +back after `esc` on any box. Letters and caret moves in an existing opening, and +automatic unmatched closes, start no new read. Until its fresh read answers, a +prefixed multi-word chat search stays up +with `looking…`. Removing the token, including clearing home's box, ends it; +the next `@` reads again. 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. @@ -354,11 +356,10 @@ 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's box has the same sections and three prefixes, with one difference: it -leaves no eligible conversation off, because its sentence opens a new one. The -three words on its first row are not buttons there; type the prefix. Typing or pasting opens -its catalogs and starts the same reads. Rows arriving keep the row you chose -selected while it is still offered. +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. diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index 1ad6918371..3133284f75 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -1484,11 +1484,12 @@ files and folders, ranked as you type, `folder` and `img` tags on the right. The 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. Typing or pasting opens the catalogs and starts the same reads. -The conversations are named or sent conversations open in this window, including other -projects and the one behind home — then the twenty most recent in this project. An unnamed -conversation with nothing sent is absent even if its unsent draft names a tab. Each time -the list appears, including a paste, it reads the recent list again. +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: a new `@` token +or the list returning on the next letter after `esc`. `↑`/`↓` 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 @@ -1525,11 +1526,17 @@ token. Only prefixed searches hold spaces, up to three and never across a newlin match every word in any order; finished words match whole, and only the final word may match by letters in order. -Every opening reads the recent list again, including a pasted search after an earlier read. -A prefixed chat search stays open with `looking…` until that fresh read answers. A prefixed -search of several words with no match closes after that read. New rows keep the -chosen row selected while it is still offered. After choosing, a space or -punctuation such as a comma keeps the list closed so you can write on. +Recents are read once per opening: a new token appearing or starting at a different +`@`, or the next letter bringing the list back after `esc`. Letters and caret moves +in an existing opening, and automatic unmatched closes, start no new read. 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 6e972ef374..6b33f77e4e 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -1595,12 +1595,16 @@ picture's `img` note. Choosing one puts its path into the sentence, exactly as a file does. It does not choose that folder as a place; `/folder` does that. The prefix words, teams and open tabs arrive from memory. Recent conversations and -the file walk arrive as they are read. Whenever the list appears, by typing or -pasting, it reads the recent list again. A prefixed multi-word conversation search waits with -`looking…` until that fresh read answers, even after an earlier read. +the file walk arrive as they are read. Recents are read once per opening: +a new `@` token, typed or pasted, or the next letter bringing the list back after +`esc` on any box. Letters, caret moves and automatic unmatched closes start no new +read. Removing the token, including clearing home's box, ends it; the next `@` +reads again. A prefixed multi-word chat search waits with `looking…` for that 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. -New data keeps the chosen row selected while it is still on the list. +New data 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. ## Searching the @ list with spaces — which words are a search @@ -1627,9 +1631,10 @@ 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. An unnamed conversation with nothing sent -is absent even if its unsent draft names a tab. Then come the twenty most recent in this -project, read again each time the list opens. Older conversations and other +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. 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. diff --git a/internal/tui3/app.go b/internal/tui3/app.go index c9c7045e11..c3442121c3 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -3908,16 +3908,16 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { a.homeFilesLoaded(msg.paths, msg.root) return a, nil } - chosen := a.comp.selectionKey(a.comp.selLine()) a.comp.all, a.comp.loaded, a.comp.loading = msg.paths, true, false a.fillMentions() a.comp.refresh(&a.input) - a.comp.restoreSelection(chosen) a.touch() return a, nil case mentionRecentsMsg: - a.mentionRecentsLoaded(msg.rows, msg.keys...) + if msg.read == 0 || msg.read == a.comp.recentsRead { + a.mentionRecentsLoaded(msg.rows, msg.keys...) + } return a, nil case tasksLoadedMsg: @@ -9211,8 +9211,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 @@ -9270,6 +9270,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 @@ -9302,19 +9303,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 || a.comp.opened) && !was { - a.comp.opened = false - // Recognizing an opening proves that `@` has been found (notice.go), - // even when its ready section closes before a frame. - 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 } @@ -9322,18 +9323,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 index d68503f550..600ff4662c 100644 --- a/internal/tui3/atcompletion_regression_test.go +++ b/internal/tui3/atcompletion_regression_test.go @@ -637,9 +637,10 @@ func TestPlainDraftTeamPaintingAllocatesNothing(t *testing.T) { } // 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"} { + 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 @@ -655,6 +656,14 @@ func TestAtListOmitsUnsentShellsOnEveryBox(t *testing.T) { 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() } @@ -665,7 +674,7 @@ func TestAtListOmitsUnsentShellsOnEveryBox(t *testing.T) { found = true } } - want := b.name != "conversation" && kind != "draft" + 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) } @@ -812,3 +821,301 @@ func TestClosedHomeArrowsLeaveMentionCatalogsAlone(t *testing.T) { 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) + }) + } + } +} + +// Each token owns its read, even if another token's answer is still off-loop. +func TestNewAtTokenReadsWhilePreviousAnswerWaits(t *testing.T) { + for _, b := range atBoxes { + t.Run(b.name, func(t *testing.T) { + a := b.make(t) + a.comp.recentsHeld, a.comp.recents = false, nil + reads := 0 + a.recentSessions = func() []Session { + reads++ + if reads == 1 { + return []Session{{File: filepath.Join(a.workspace, "unique.jsonl"), Title: "unique words"}} + } + return nil + } + _, first := a.Update(tea.PasteMsg{Content: "@chat:old words"}) + for b.box(a) != "" { + a.Update(key("backspace")) + } + _, second := a.Update(tea.PasteMsg{Content: "@chat:unique words"}) + spend(t, a, second) + if reads != 1 || !b.comp(a).open || len(b.comp(a).chatHits) != 1 { + t.Fatalf("new token did not start its own read: reads=%d open=%v rows=%+v", reads, b.comp(a).open, b.comp(a).chatHits) + } + spend(t, a, first) + if reads != 2 || !b.comp(a).open || len(b.comp(a).chatHits) != 1 { + t.Fatalf("old answer displaced the new token's catalog: reads=%d open=%v rows=%+v", reads, b.comp(a).open, b.comp(a).chatHits) + } + }) + } +} + +// 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) + } + } + } +} diff --git a/internal/tui3/files.go b/internal/tui3/files.go index c72fb71937..08a3132b4c 100644 --- a/internal/tui3/files.go +++ b/internal/tui3/files.go @@ -78,9 +78,10 @@ var skipDirs = map[string]bool{ // keeps its walk across closes: the second @ of a session opens instantly. type completion struct { open bool - // opened records a new @ search even when a ready unmatched section - // immediately closes it, because every opening still reads recents. - opened 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 @@ -90,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 @@ -145,6 +145,9 @@ type completion struct { // 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 + // recentsRead identifies the newest read, so an earlier opening's answer + // cannot settle a newer search. 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 @@ -155,6 +158,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 @@ -181,7 +187,6 @@ func deadLine() compLine { // draft is still what it is about — but it answers to [argToken] instead, and // it stays open while the person types a path with spaces in it. func (c *completion) sync(e *editor) { - c.opened = false if strings.HasPrefix(strings.TrimSpace(e.String()), "!") { c.close() return @@ -209,6 +214,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 } @@ -225,7 +236,6 @@ func (c *completion) sync(e *editor) { return } } - c.opened = !c.open 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 @@ -238,6 +248,44 @@ func (c *completion) sync(e *editor) { } } +// 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 != "" && 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) { @@ -252,12 +300,12 @@ func (c *completion) refresh(e *editor) { // 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 @@ -274,6 +322,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 @@ -390,14 +448,12 @@ func (c *completion) selectionKey(at int) string { } // restoreSelection follows the chosen row through a fresh ranking. A row -// removed by the new data leaves the ordinary clamped cursor in force. +// removed by the new data, or never chosen, gives the best match the cursor. func (c *completion) restoreSelection(chosen string) { - if chosen == "" { - return - } + c.cursor, c.top, c.chosen = 0, 0, "" for cursor, at := range c.sel { - if c.selectionKey(at) == chosen { - c.cursor = cursor + if chosen != "" && c.selectionKey(at) == chosen { + c.cursor, c.chosen = cursor, chosen break } } @@ -437,8 +493,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 @@ -593,6 +648,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()) } diff --git a/internal/tui3/home.go b/internal/tui3/home.go index 6161a7297f..ccc0957fd4 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 diff --git a/internal/tui3/homeat.go b/internal/tui3/homeat.go index faa344eb50..a114868b3e 100644 --- a/internal/tui3/homeat.go +++ b/internal/tui3/homeat.go @@ -159,7 +159,7 @@ func (h *homeView) completionPath(line homeLine) (string, bool) { func (a *app) fillHomeMentions() { a.home.comp.teams = a.mentionTeams() a.home.comp.chats = a.mentionChatsExcept("") - a.home.comp.recentsLoaded = a.mentionRecentsReady(a.home.comp.open) + a.home.comp.recentsLoaded = a.mentionRecentsReady(true) } // homeCompletionRoot uses the explicit pin, otherwise the conversation's file @@ -175,8 +175,7 @@ func (a *app) homeCompletionRoot() string { // outside a search leave the completed file walk and the mention catalogs alone. func (a *app) prepareHomeCompletion(text string) { h := &a.home - h.comp.opened = false - h.comp.recentsLoaded = a.mentionRecentsReady(h.comp.open) + h.comp.recentsLoaded = a.mentionRecentsReady(true) if !h.comp.open { if text == "" { return @@ -206,16 +205,20 @@ func (a *app) prepareHomeCompletion(text string) { // 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(was bool) tea.Cmd { +func (a *app) syncHomeCompletion(_ bool) tea.Cmd { h := &a.home root := a.homeCompletionRoot() var recents tea.Cmd - if (h.comp.open || h.comp.opened) && !was { + opening := h.comp.beginToken(&h.box) + if opening { + a.noticeEvent(eventAtOpened) recents = a.loadMentionRecents() a.fillHomeMentions() a.alignHomeFiles(root) + } + h.comp.sync(&h.box) + if opening || h.comp.open { h.build() - h.comp.opened = false } return tea.Batch(a.loadHomeFiles(root), recents) } @@ -258,10 +261,8 @@ func (a *app) homeFilesLoaded(paths []string, root string) { if root != "" && root != h.walked { return } - chosen := h.comp.selectionKey(h.cursor) h.comp.all, h.comp.loaded, h.comp.loading = paths, true, false h.comp.rank() - h.comp.restoreSelection(chosen) h.build() a.touch() } @@ -329,8 +330,9 @@ func (a *app) homeComplete(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/mention.go b/internal/tui3/mention.go index d3c13f7697..14b6e570c3 100644 --- a/internal/tui3/mention.go +++ b/internal/tui3/mention.go @@ -238,13 +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(a.comp.open) + a.comp.recentsLoaded = a.mentionRecentsReady(true) } -// mentionRecentsReady keeps an opening's cached rows visible while treating -// its fresh read as pending. Further edits wait for that same read to finish. -func (a *app) mentionRecentsReady(open bool) bool { - return a.recentSessions == nil || !a.comp.recentsPending && (open || a.comp.recentsHeld) && (a.comp.recentsLoaded || len(a.comp.recents) > 0) +// 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 { @@ -308,31 +308,25 @@ func (a *app) mentionChatsExcept(front string) []mentionChat { return out } -// mentionableTab distinguishes a conversation's own name or sent opening -// from the unsent words the strip may borrow. This list never reads a transcript. +// 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 tab.key == a.frontTabKey() { - if named(a.title) || strings.TrimSpace(a.openingPrompt) != "" { - return true - } - for _, e := range a.entries { - if e.kind == entryUser { - return true - } - } - return false + if named(a.title) || strings.TrimSpace(a.openingPrompt) != "" { + return true } - if held := a.behind[tab.key]; held != nil { - if held.conv.Agent != nil && named(held.conv.Agent.Title()) { + for _, e := range a.entries { + if e.kind == entryUser { return true } - return held.side != nil && (named(held.side.title) || strings.TrimSpace(held.side.openingPrompt) != "") } - return named(tab.full) + return false } func (a *app) mentionFromTab(tab chatTab, open bool) mentionChat { @@ -374,22 +368,26 @@ func (a *app) mentionHandle(key string) string { type mentionRecentsMsg struct { rows []Session keys []string + read uint64 } // 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. // -// IT RUNS ON EVERY OPENING OF THE LIST, not once per process. The read is the -// door's own bounded walk — twenty transcripts at most, on the keystroke that -// asks (cmd/codeaf's v3RecentSessions) — and the keystroke is "@", not every -// letter after it: [app.syncLists] asks only when the list was closed and is -// now open. One read is held at a time; a second "@" while the first is still -// walking waits for that answer rather than starting another walk. +// IT RUNS ON EVERY OPENING, not once per process. A new @ token or the list +// returning after Escape starts an opening. The read is the door's own bounded +// walk — twenty transcripts at most, on the keystroke that asks (cmd/codeaf's +// v3RecentSessions), never each following letter. [completion.beginToken] +// recognizes openings independently of automatic display closes. A new opening +// starts its own read even while an earlier answer is pending; only the newest +// answer settles the shared catalog. 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 } 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() @@ -410,7 +408,7 @@ func (a *app) loadMentionRecents() tea.Cmd { keys[i] = convKey(file) } } - return mentionRecentsMsg{rows: list, keys: keys} + return mentionRecentsMsg{rows: list, keys: keys, read: readID} } } @@ -418,8 +416,6 @@ func (a *app) loadMentionRecents() tea.Cmd { // 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. - chosen := a.comp.selectionKey(a.comp.selLine()) - homeChosen := a.home.comp.selectionKey(a.home.cursor) a.comp.recentsHeld, a.comp.recentsLoaded, a.comp.recentsPending = false, true, false a.comp.recents = a.comp.recents[:0] seen := map[string]bool{} @@ -456,13 +452,11 @@ func (a *app) mentionRecentsLoaded(rows []Session, keys ...string) { if a.comp.open { a.fillMentions() a.comp.refresh(&a.input) - a.comp.restoreSelection(chosen) } // 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.comp.restoreSelection(homeChosen) a.home.build() } a.touch() From 58a967cece07bac720ef70d5d1486cc2c293a0e7 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 2 Oct 2026 14:03:19 -0400 Subject: [PATCH 10/10] chat: one recent walk in flight, and esc then punctuation stays closed The previous commit let every new @ token start its own recent walk while earlier ones were still running, and each walk reads every session folder's meta.json: 64 quick token replacements meant 64 concurrent walks. Now one walk is in flight per window; openings while it runs share one follow-up walk after it lands, and the answer that began before them never settles their search as fresh. A burst costs at most two walks. After esc on a bare @, a comma or a period reopened the list and read again, because the separator rule only looked at a non-empty query. A space or punctuation after esc now keeps the list closed for a bare @ as it does for a typed one; a letter still reopens it with one read. Co-Authored-By: Claude Opus 5.5 --- ...725-mention-list-recents-and-chat-scope.md | 6 +- internal/manual/chat/attaching-files.md | 12 +- .../manual/chat/conversations-and-teams.md | 15 +- internal/manual/chat/home.md | 16 +- internal/manual/chat/keys.md | 30 +- internal/tui3/app.go | 6 + internal/tui3/atcompletion_regression_test.go | 368 +++++++++++++++++- internal/tui3/files.go | 13 +- internal/tui3/homeat.go | 4 +- internal/tui3/mention.go | 19 +- 10 files changed, 423 insertions(+), 66 deletions(-) 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 index dfc0c79c8c..184d06f462 100644 --- a/docs/changes/unreleased/1725-mention-list-recents-and-chat-scope.md +++ b/docs/changes/unreleased/1725-mention-list-recents-and-chat-scope.md @@ -4,16 +4,16 @@ title: the @ list finds recent conversations on every box without taking over pr 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: a new `@` token, typed or pasted, or the list returning on the next letter after `esc`." + - "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 reads recents exactly once, even when its unmatched display closes; 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; 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." + - "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 `@` starts a fresh read." + - "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." diff --git a/internal/manual/chat/attaching-files.md b/internal/manual/chat/attaching-files.md index 7bbf9aec00..0b3995bd00 100644 --- a/internal/manual/chat/attaching-files.md +++ b/internal/manual/chat/attaching-files.md @@ -565,8 +565,8 @@ The conversations on it are the tabs open in this window, including other projec 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: a new `@` token or the list -returning on the next letter after `esc`. For older or other-project conversations +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. @@ -600,9 +600,11 @@ 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: a new token appearing or starting at a different -`@`, or the next letter bringing the list back after `esc`. Letters and caret moves -in an existing opening, and automatic unmatched closes, start no new read. Removing the token, including clearing home's box, +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. diff --git a/internal/manual/chat/conversations-and-teams.md b/internal/manual/chat/conversations-and-teams.md index d93bc21206..7ffdfde1fc 100644 --- a/internal/manual/chat/conversations-and-teams.md +++ b/internal/manual/chat/conversations-and-teams.md @@ -341,14 +341,13 @@ 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, by when -you last spoke in them. They are read once per opening: a new token appearing or -starting at a different `@`, typed or pasted, or the next letter bringing the list -back after `esc` on any box. Letters and caret moves in an existing opening, and -automatic unmatched closes, start no new read. Until its fresh read answers, a -prefixed multi-word chat search stays up -with `looking…`. Removing the token, including clearing home's box, ends it; -the next `@` reads again. +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. diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index 3133284f75..63df9ea87c 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -1488,15 +1488,15 @@ 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: a new `@` token -or the list returning on the next letter after `esc`. +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 — 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 +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 @@ -1526,9 +1526,11 @@ token. Only prefixed searches hold spaces, up to three and never across a newlin 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: a new token appearing or starting at a different -`@`, or the next letter bringing the list back after `esc`. Letters and caret moves -in an existing opening, and automatic unmatched closes, start no new read. Clearing the box ends the token and closes its list; +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. diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index 6b33f77e4e..e4a5115574 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -1590,21 +1590,22 @@ 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, exactly as a -file does. It does not choose that folder as a place; `/folder` does that. - -The prefix words, teams and open tabs arrive from memory. Recent conversations and -the file walk arrive as they are read. Recents are read once per opening: -a new `@` token, typed or pasted, or the next letter bringing the list back after -`esc` on any box. Letters, caret moves and automatic unmatched closes start no new -read. Removing the token, including clearing home's box, ends it; the next `@` -reads again. A prefixed multi-word chat search waits with `looking…` for that answer. +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. -New data 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. +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 @@ -1634,7 +1635,8 @@ 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. Older conversations and other +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. diff --git a/internal/tui3/app.go b/internal/tui3/app.go index c3442121c3..4fe70393dc 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -3916,6 +3916,12 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { case mentionRecentsMsg: 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 diff --git a/internal/tui3/atcompletion_regression_test.go b/internal/tui3/atcompletion_regression_test.go index 600ff4662c..f7aae6d01f 100644 --- a/internal/tui3/atcompletion_regression_test.go +++ b/internal/tui3/atcompletion_regression_test.go @@ -5,7 +5,11 @@ import ( "os" "os/exec" "path/filepath" + "reflect" + "runtime" "strings" + "sync" + "sync/atomic" "testing" "time" @@ -949,33 +953,97 @@ func TestEscEndsAtOpeningAndNextLetterReadsFreshOnEveryBox(t *testing.T) { } } -// Each token owns its read, even if another token's answer is still off-loop. -func TestNewAtTokenReadsWhilePreviousAnswerWaits(t *testing.T) { +// 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.recents = false, nil - reads := 0 + 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 { - reads++ - if reads == 1 { - return []Session{{File: filepath.Join(a.workspace, "unique.jsonl"), Title: "unique words"}} + 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 } - return 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) != "" { - a.Update(key("backspace")) + _, cmd := a.Update(key("backspace")) + start(cmd) } _, second := a.Update(tea.PasteMsg{Content: "@chat:unique words"}) - spend(t, a, second) - if reads != 1 || !b.comp(a).open || len(b.comp(a).chatHits) != 1 { - t.Fatalf("new token did not start its own read: reads=%d open=%v rows=%+v", reads, b.comp(a).open, b.comp(a).chatHits) + 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()) } - spend(t, a, first) - if reads != 2 || !b.comp(a).open || len(b.comp(a).chatHits) != 1 { - t.Fatalf("old answer displaced the new token's catalog: reads=%d open=%v rows=%+v", reads, b.comp(a).open, b.comp(a).chatHits) + 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) }) } } @@ -1119,3 +1187,273 @@ func TestMentionManualExplainsTokenOpeningsAndDeliberateChoices(t *testing.T) { } } } + +// 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/files.go b/internal/tui3/files.go index 08a3132b4c..2b363f8ae8 100644 --- a/internal/tui3/files.go +++ b/internal/tui3/files.go @@ -125,8 +125,8 @@ type completion struct { // teams and chats are the catalogs this list ranks, copied from memory on // the update loop (mention.go). recents is the recent-conversation snapshot, - // read off the loop because that list can touch the disk, and read again - // each time the list opens so a conversation started since is on it. + // 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 @@ -145,8 +145,11 @@ type completion struct { // 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 - // recentsRead identifies the newest read, so an earlier opening's answer - // cannot settle a newer search. Only the app's shared catalog uses it. + // 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 @@ -275,7 +278,7 @@ func (c *completion) beginToken(e *editor) bool { return false } // A completed mention or a separator after Escape keeps the list closed. - if (previous == 0 || previous == -token) && c.done != "" && at == c.at && strings.HasPrefix(query, c.done) { + 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 diff --git a/internal/tui3/homeat.go b/internal/tui3/homeat.go index a114868b3e..418913803a 100644 --- a/internal/tui3/homeat.go +++ b/internal/tui3/homeat.go @@ -29,7 +29,7 @@ import ( // 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 read again on each opening exactly as there, and choosing a +// 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 @@ -192,7 +192,7 @@ func (a *app) prepareHomeCompletion(text string) { if !ok { return } - if h.comp.done != "" && at == h.comp.at && strings.HasPrefix(query, h.comp.done) { + 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 diff --git a/internal/tui3/mention.go b/internal/tui3/mention.go index 14b6e570c3..51e885b7ae 100644 --- a/internal/tui3/mention.go +++ b/internal/tui3/mention.go @@ -374,17 +374,21 @@ type mentionRecentsMsg struct { // 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. // -// IT RUNS ON EVERY OPENING, not once per process. A new @ token or the list -// returning after Escape starts an opening. The read is the door's own bounded -// walk — twenty transcripts at most, on the keystroke that asks (cmd/codeaf's -// v3RecentSessions), never each following letter. [completion.beginToken] -// recognizes openings independently of automatic display closes. A new opening -// starts its own read even while an earlier answer is pending; only the newest -// answer settles the shared catalog. +// 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.recentSessions == nil || a.comp.recentsHeld && !a.comp.recentsPending { return nil } + if a.comp.recentsPending { + a.comp.recentsAgain = true + return nil + } a.comp.recentsHeld, a.comp.recentsLoaded, a.comp.recentsPending = true, false, true a.comp.recentsRead++ readID := a.comp.recentsRead @@ -417,6 +421,7 @@ func (a *app) loadMentionRecents() tea.Cmd { 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 i, row := range rows {