From 300141ad948f78058ed24ac187d106aa14b6ac23 Mon Sep 17 00:00:00 2001 From: Cheng Peng Date: Fri, 6 Nov 2026 00:01:45 -0800 Subject: [PATCH 1/2] fix(composer): open @, # and : suggestions after CJK punctuation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chinese and Japanese prose puts no space after fullwidth punctuation, so 你好,@alice, (@alice and 看看,#general are ordinary text. The mention, channel and emoji queries only opened at the start of the text or after whitespace, (, [ or {, so none of those menus opened there. The three queries now share TRIGGER_BOUNDARY from mention-query.ts, which adds the fullwidth and CJK punctuation that ends a bare link since #2404. Letters and digits still never count, so a@host and word#tag stay text. Mention rules section 2 and its query fixtures now say the same. Co-authored-by: Claude Opus 5.5 Signed-off-by: Cheng Peng Co-authored-by: Cursor --- src/bundled/channels/channel-query.test.ts | 7 +++++++ src/bundled/channels/channel-query.ts | 6 ++++-- src/bundled/emoji/emoji-query.test.ts | 6 ++++++ src/bundled/emoji/emoji-query.ts | 6 ++++-- src/bundled/mentions/README.md | 4 +++- src/bundled/mentions/mention-query.test.ts | 8 ++++++++ src/bundled/mentions/mention-query.ts | 12 +++++++++--- .../mentions/mention-rules.fixtures.json | 10 ++++++++++ src/features/messages/MessageComposer.test.tsx | 17 +++++++++++++++++ 9 files changed, 68 insertions(+), 8 deletions(-) diff --git a/src/bundled/channels/channel-query.test.ts b/src/bundled/channels/channel-query.test.ts index 0d5cda1de1..092344f212 100644 --- a/src/bundled/channels/channel-query.test.ts +++ b/src/bundled/channels/channel-query.test.ts @@ -43,3 +43,10 @@ it("uses the caret, rejects invalid offsets, and bounds the query", () => { const text = `#${"a".repeat(160)}`; expect(channelQuery(text, text.length)).toBeNull(); }); +it("matches after fullwidth and CJK punctuation, also at the scan bound", () => { + for (const text of ["看看,#ge", "好。#ge", "(#ge", "「#ge"]) { + expect(channelQuery(text, text.length)?.start).toBe(text.lastIndexOf("#")); + } + const bound = `,#${"a".repeat(159)}`; + expect(channelQuery(bound, bound.length)?.start).toBe(1); +}); diff --git a/src/bundled/channels/channel-query.ts b/src/bundled/channels/channel-query.ts index a084e927e1..e3e82bbfcf 100644 --- a/src/bundled/channels/channel-query.ts +++ b/src/bundled/channels/channel-query.ts @@ -1,12 +1,14 @@ +import { TRIGGER_BOUNDARY } from "../mentions/mention-query"; + /** Syntax only: no channel reads or work for ordinary typing. A query may * contain spaces; completion admits it only while a channel name continues it. */ export function channelQuery(text: string, caret: number) { if (!Number.isInteger(caret) || caret < 0 || caret > text.length) return null; const before = text.slice(Math.max(0, caret - 160), caret); - const match = before.match(/(?:^|[\s([{])#([^\n\r\t#[\](){}`<>\\]*)$/u); + const match = before.match(/#([^\n\r\t#[\](){}`<>\\]*)$/u); if (!match) return null; const query = match[1] ?? ""; const start = caret - query.length - 1; - if (start && !/[\s([{]/u.test(text[start - 1] ?? "")) return null; + if (start && !TRIGGER_BOUNDARY.test(text[start - 1] ?? "")) return null; return { start, end: caret, query }; } diff --git a/src/bundled/emoji/emoji-query.test.ts b/src/bundled/emoji/emoji-query.test.ts index 0a0c4b7322..aab0835faa 100644 --- a/src/bundled/emoji/emoji-query.test.ts +++ b/src/bundled/emoji/emoji-query.test.ts @@ -18,6 +18,12 @@ it("opens after two shortcode characters, at the real caret and word boundaries" } }); +it("opens after fullwidth and CJK punctuation", () => { + for (const text of ["好的,:sm", "(:sm", "「:sm"]) { + expect(emojiQuery(text, text.length)?.start).toBe(text.lastIndexOf(":")); + } +}); + it("opens right after a converted emoji or a completed shortcode, without a space", () => { // `:smile:` converts to its glyph on the closing colon; the next shortcode starts at once. expect(emojiQuery("😄:wa", 5)).toEqual({ start: 2, end: 5, query: "wa" }); diff --git a/src/bundled/emoji/emoji-query.ts b/src/bundled/emoji/emoji-query.ts index 0c2170a29d..2a776353e4 100644 --- a/src/bundled/emoji/emoji-query.ts +++ b/src/bundled/emoji/emoji-query.ts @@ -1,3 +1,5 @@ +import { TRIGGER_BOUNDARY } from "../mentions/mention-query"; + const SHORTCODE = "[a-zA-Z0-9_+-]{2,64}"; const QUERY = new RegExp(`:(${SHORTCODE})$`, "u"); const CLOSED = new RegExp(`:${SHORTCODE}:$`, "u"); @@ -7,7 +9,7 @@ const EMOJI = /(?:\p{Extended_Pictographic}|\p{Emoji_Modifier}|\p{Regional_Indicator}|\u20E3|\u{E007F})[\uFE0E\uFE0F]?$/u; /** - * Start/whitespace/opening-bracket boundaries exclude URLs, times and closed codes. + * The start of text and `TRIGGER_BOUNDARY` exclude URLs, times and closed codes. * A converted emoji or a completed shortcode at its own boundary also counts, so * `😄:wave` and `:bufo::wave` chain without a space while `std::vector` stays text. */ @@ -23,7 +25,7 @@ export function emojiQuery(text: string, caret: number) { } function atBoundary(prefix: string): boolean { - if (prefix === "" || /[\s([{]$/u.test(prefix) || EMOJI.test(prefix)) + if (!prefix || TRIGGER_BOUNDARY.test(prefix.slice(-1)) || EMOJI.test(prefix)) return true; const closed = prefix.match(CLOSED); return closed !== null && atBoundary(prefix.slice(0, -closed[0].length)); diff --git a/src/bundled/mentions/README.md b/src/bundled/mentions/README.md index 47caa10ccf..c91c6b3d87 100644 --- a/src/bundled/mentions/README.md +++ b/src/bundled/mentions/README.md @@ -88,7 +88,9 @@ namesakes get qualified labels. ## 2. Query syntax Inline completion opens on `@` at the start of the text or after whitespace, -`(`, `[`, or `{`. The query is the text from after `@` up to the caret. It MUST +`(`, `[`, `{`, or fullwidth or CJK punctuation, which CJK prose does not follow +with a space: `,。、.!?;:"'()[]{}「」『』《》【】〈〉〔〕〖〗〝〞〟。「」、“”‘’…~〜`. +The query is the text from after `@` up to the caret. It MUST NOT contain `@`, a line break, or a tab. It MAY contain spaces. A client MAY look back only 160 characters from the caret. A caret outside the text gives no query. diff --git a/src/bundled/mentions/mention-query.test.ts b/src/bundled/mentions/mention-query.test.ts index 82b9126f64..1b028ed79c 100644 --- a/src/bundled/mentions/mention-query.test.ts +++ b/src/bundled/mentions/mention-query.test.ts @@ -16,6 +16,14 @@ it("opens at word/open-bracket boundaries without treating email/URLs as mention expect(mentionQuery(text, text.length)).toBeNull(); } }); +it("opens after fullwidth and CJK punctuation, which CJK prose does not follow with a space", () => { + for (const text of ["你好,@Ho", "好的。@Ho", "(@Ho", "「@Ho", "“@Ho"]) { + expect(mentionQuery(text, text.length)?.start).toBe(text.lastIndexOf("@")); + } + // At the scan bound, the character before `@` still comes from the full text. + const bound = `,@${"x".repeat(159)}`; + expect(mentionQuery(bound, bound.length)?.start).toBe(1); +}); it("uses UTF-16 ranges at the actual caret and respects the scan bound", () => { expect(mentionQuery("🧠 @Ho tail", 7)).toEqual({ start: 3, diff --git a/src/bundled/mentions/mention-query.ts b/src/bundled/mentions/mention-query.ts index e961c9eecb..b87142bbe8 100644 --- a/src/bundled/mentions/mention-query.ts +++ b/src/bundled/mentions/mention-query.ts @@ -1,11 +1,17 @@ import { profileKey } from "../../features/profiles/target"; import { foldName, normalizeName } from "../../features/search/person-match"; +/** What may precede `@`, `#` or `:` for a completion to open: whitespace, an + * opening bracket, or fullwidth/CJK punctuation, which CJK prose does not + * follow with a space. */ +export const TRIGGER_BOUNDARY = + /[\s([{,。、.!?;:"'()[]{}「」『』《》【】〈〉〔〕〖〗〝〞〟。「」、“”‘’…~〜]/u; + /** Syntax only. Multi-word queries are admitted separately against current names. */ export function mentionQuery(text: string, caret: number) { if (!Number.isInteger(caret) || caret < 0 || caret > text.length) return null; const before = text.slice(Math.max(0, caret - 160), caret); - const match = before.match(/(?:^|[\s([{])@([^@\n\r\t]*)$/u); + const match = before.match(/@([^@\n\r\t]*)$/u); if (!match) return null; const query = match[1] ?? ""; const start = caret - query.length - 1; @@ -14,8 +20,8 @@ export function mentionQuery(text: string, caret: number) { /\]\((nostr:npub1[023456789acdefghjklmnpqrstuvwxyz]+)\)$/u.exec(query); if (text[start - 1] === "[" && completed && profileKey(completed[1] ?? "")) return null; - // A bounded slice beginning in the middle of a word is not a boundary. - if (start && !/[\s([{]/u.test(text[start - 1] ?? "")) return null; + // Check the full text: a bounded slice can begin in the middle of a word. + if (start && !TRIGGER_BOUNDARY.test(text[start - 1] ?? "")) return null; return { start, end: caret, query }; } diff --git a/src/bundled/mentions/mention-rules.fixtures.json b/src/bundled/mentions/mention-rules.fixtures.json index ef03f03c90..72be6ef787 100644 --- a/src/bundled/mentions/mention-rules.fixtures.json +++ b/src/bundled/mentions/mention-rules.fixtures.json @@ -1252,6 +1252,16 @@ "query": "Ho" } }, + { + "name": "after_fullwidth_punctuation", + "text": "你好,@Ho", + "caret": 6, + "expected": { + "start": 3, + "end": 6, + "query": "Ho" + } + }, { "name": "after_newline", "text": "@x\n@y", diff --git a/src/features/messages/MessageComposer.test.tsx b/src/features/messages/MessageComposer.test.tsx index 230d5779e6..131fa7f18a 100644 --- a/src/features/messages/MessageComposer.test.tsx +++ b/src/features/messages/MessageComposer.test.tsx @@ -1553,6 +1553,23 @@ it("reopens a query at the same offset after Escape with no carried rows", () => expect(h.messages.send).not.toHaveBeenCalled(); }); +it("opens an @ query after CJK punctuation and sends the pick as a recipient", async () => { + const h = mountTypeahead(); + const input = h.input(); + input.focus(); + h.fill("你好,@honey"); + expect(h.rows()).toEqual(["Honey honey"]); + expect(fireEvent.keyDown(input, { key: " " })).toBe(false); + expect(input).toHaveValue("你好,@Honey "); + fireEvent.click(screen.getByRole("button", { name: "Send message" })); + await waitFor(() => expect(h.messages.send).toHaveBeenCalledOnce()); + expect(h.messages.send.mock.calls[0]?.slice(0, 3)).toEqual([ + "channel", + "你好,@Honey ", + [first.pubkey], + ]); +}); + it("still closes the popup when the query closes, on Escape, and on a Space that selects", () => { const h = mountTypeahead(); const input = h.input(); From 5602a2241fd8c728d48698b18f3358a76b010280 Mon Sep 17 00:00:00 2001 From: Cheng Peng Date: Fri, 6 Nov 2026 00:37:21 -0800 Subject: [PATCH 2/2] docs(composer): note CJK trigger punctuation in help; trim a repeated case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mention query test repeated the portable fixture's 你好,@Ho, so that case now lives only in the fixture. Mention rules section 2 calls the list punctuation marks rather than fullwidth or CJK punctuation, since “”‘’… are general punctuation. The agent help guide says the @, # and : suggestions open right after CJK punctuation without a space. Co-authored-by: Claude Opus 5.5 Signed-off-by: Cheng Peng Co-authored-by: Cursor --- docs/buzz-help.md | 2 ++ src/bundled/mentions/README.md | 4 ++-- src/bundled/mentions/mention-query.test.ts | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/buzz-help.md b/docs/buzz-help.md index e3991357ce..2d439fe341 100644 --- a/docs/buzz-help.md +++ b/docs/buzz-help.md @@ -361,6 +361,8 @@ revokes all of them, and a pairing unused for 30 days expires. - **Emoji suggestions** appear when you type `:` and an emoji name. If a name is shortened with an ellipsis, hover over it or use the arrow keys to select it and see the full name in a tooltip. +- **Mention, channel and emoji suggestions** also open when you type `@`, `#` + or `:` right after CJK punctuation such as `,` or `(`, without a space. - **Spoilers** stay hidden in reminder previews and notifications as `[Spoiler]`. Open the original message to reveal them. - **Canvas** holds shared notes and context for a channel or chat. Messages and diff --git a/src/bundled/mentions/README.md b/src/bundled/mentions/README.md index c91c6b3d87..48abed7535 100644 --- a/src/bundled/mentions/README.md +++ b/src/bundled/mentions/README.md @@ -88,8 +88,8 @@ namesakes get qualified labels. ## 2. Query syntax Inline completion opens on `@` at the start of the text or after whitespace, -`(`, `[`, `{`, or fullwidth or CJK punctuation, which CJK prose does not follow -with a space: `,。、.!?;:"'()[]{}「」『』《》【】〈〉〔〕〖〗〝〞〟。「」、“”‘’…~〜`. +`(`, `[`, `{`, or one of these punctuation marks, which CJK prose does not +follow with a space: `,。、.!?;:"'()[]{}「」『』《》【】〈〉〔〕〖〗〝〞〟。「」、“”‘’…~〜`. The query is the text from after `@` up to the caret. It MUST NOT contain `@`, a line break, or a tab. It MAY contain spaces. A client MAY look back only 160 characters from the caret. A caret outside the text gives no query. diff --git a/src/bundled/mentions/mention-query.test.ts b/src/bundled/mentions/mention-query.test.ts index 1b028ed79c..5cc7102166 100644 --- a/src/bundled/mentions/mention-query.test.ts +++ b/src/bundled/mentions/mention-query.test.ts @@ -17,7 +17,7 @@ it("opens at word/open-bracket boundaries without treating email/URLs as mention } }); it("opens after fullwidth and CJK punctuation, which CJK prose does not follow with a space", () => { - for (const text of ["你好,@Ho", "好的。@Ho", "(@Ho", "「@Ho", "“@Ho"]) { + for (const text of ["好的。@Ho", "(@Ho", "「@Ho", "“@Ho"]) { expect(mentionQuery(text, text.length)?.start).toBe(text.lastIndexOf("@")); } // At the scan bound, the character before `@` still comes from the full text.