From 2a47fc905a7a1054823919980f625b7cd869f1c1 Mon Sep 17 00:00:00 2001 From: Wilgot Date: Thu, 17 Sep 2026 06:19:50 +0100 Subject: [PATCH] fix(skills): support unicode currency symbols as skill aliases (#12098) Co-authored-by: maria-rcks --- .../src/nativeMarkdownText.ts | 4 +- apps/mobile/src/components/ComposerEditor.tsx | 5 ++- .../threads/use-composer-command-menu.ts | 2 +- .../mobile/src/lib/nativeMarkdownText.test.ts | 43 ++++++++++--------- .../Drivers/ClaudeSkillDispatch.test.ts | 33 ++++++++++++-- .../provider/Drivers/ClaudeSkillDispatch.ts | 7 +-- .../src/provider/Drivers/CursorSkills.ts | 4 +- .../Layers/CodexSessionRuntime.test.ts | 17 ++++++++ .../provider/Layers/CodexSessionRuntime.ts | 6 ++- .../provider/Layers/CursorProvider.test.ts | 16 +++++++ .../src/components/chat/SkillInlineText.tsx | 4 +- apps/web/src/composer-editor-mentions.test.ts | 8 ++-- apps/web/src/composer-editor-mentions.ts | 3 +- .../src/composer-list-continuation.test.ts | 6 +++ apps/web/src/composer-list-continuation.ts | 1 - apps/web/src/composer-logic.test.ts | 42 +++++++++++------- apps/web/src/composer-logic.ts | 9 ++-- apps/web/src/composer-rich-text-doc.test.ts | 10 +++++ apps/web/src/providerSkillSearch.test.ts | 8 ++++ apps/web/src/providerSkillSearch.ts | 2 +- .../shared/src/composerInlineTokens.test.ts | 31 ++++++++----- packages/shared/src/composerInlineTokens.ts | 2 +- packages/shared/src/composerTrigger.test.ts | 17 +++++++- packages/shared/src/composerTrigger.ts | 5 ++- 24 files changed, 208 insertions(+), 77 deletions(-) diff --git a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts index ec8cf74fee2b4db7120c25071176b6c7316ae0e4..04682874db0c7ce7806d99fb2aec9a6f587164e3 100644 --- a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts +++ b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts @@ -319,7 +319,7 @@ function appendRun( } const SKILL_TOKEN_REGEX = - /(^|\s)\$(?![0-9][0-9_]*(?:[kKmMbBtT]|[eE][0-9]+)?(?:\s|$))(?=[a-zA-Z0-9:_-]*[a-zA-Z])([a-zA-Z0-9][a-zA-Z0-9:_-]*)(?=\s|$)/g; + /(^|\s)\p{Sc}(?![0-9][0-9_]*(?:[kKmMbBtT]|[eE][0-9]+)?(?:\s|$))(?=[a-zA-Z0-9:_-]*[a-zA-Z])([a-zA-Z0-9][a-zA-Z0-9:_-]*)(?=\s|$)/gu; function formatSkillLabel(skill: SelectableMarkdownSkill): string { const displayName = skill.displayName?.trim(); @@ -359,7 +359,7 @@ function decorateSkillRuns( continue; } const start = (match.index ?? 0) + prefix.length; - const end = start + name.length + 1; + const end = (match.index ?? 0) + match[0].length; if (start > cursor) { decorated.push({ ...run, text: run.text.slice(cursor, start) }); } diff --git a/apps/mobile/src/components/ComposerEditor.tsx b/apps/mobile/src/components/ComposerEditor.tsx index f9112d78fb4109a51c187d8a76eb9eb1a51e97ba..a697ad6bc11f5f81c1de5900bff6bb830e87c142 100644 --- a/apps/mobile/src/components/ComposerEditor.tsx +++ b/apps/mobile/src/components/ComposerEditor.tsx @@ -155,8 +155,9 @@ export function ComposerEditor({ const selectedReference = selected ? collectComposerContextReferences(selected.source)[0] : undefined; - const selectedSkill = selected?.source.startsWith("$") - ? props.skills?.find((skill) => skill.name === selected.source.slice(1)) + const selectedSkillName = selected?.source.match(/^\p{Sc}(.+)$/u)?.[1]; + const selectedSkill = selectedSkillName + ? props.skills?.find((skill) => skill.name === selectedSkillName) : undefined; const record = draft.context?.records.find( (entry) => entry.contextId === selectedReference?.contextId, diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.ts b/apps/mobile/src/features/threads/use-composer-command-menu.ts index f2ff30fcd1f5c08d47e3a50a7ddc97345cf05a64..b0b2cd42ecba9f6c4e93686c61f2958da8800503 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -366,7 +366,7 @@ export function useComposerCommandMenu({ if (trigger.kind === "skill") { const enabledSkills = dedupeProviderSkillsByName(skills.filter(isProviderSkillUserInvocable)); const normalizedQuery = normalizeSearchQuery(trigger.query, { - trimLeadingPattern: /^\$+/, + trimLeadingPattern: /^\p{Sc}+/u, }); if (!normalizedQuery) { diff --git a/apps/mobile/src/lib/nativeMarkdownText.test.ts b/apps/mobile/src/lib/nativeMarkdownText.test.ts index 3c4fa3ad0ae65a120020be9788999dcd8148b8d5..d934a306aa01d3a4396f9e26b5a1933c03abbdd7 100644 --- a/apps/mobile/src/lib/nativeMarkdownText.test.ts +++ b/apps/mobile/src/lib/nativeMarkdownText.test.ts @@ -307,28 +307,31 @@ describe("nativeMarkdownDocumentRuns", () => { ]); }); - it("decorates known skill references as selectable skill links", () => { - const node: MarkdownNode = { - type: "document", - children: [ + it.each(["$", "€", "£", "¥", "₹", "₩", "₿", "𑿝"])( + "decorates %s skill references as selectable skill links", + (prefix) => { + const node: MarkdownNode = { + type: "document", + children: [ + { + type: "paragraph", + children: [{ type: "text", content: `Use ${prefix}ui for this.` }], + }, + ], + }; + + expect(nativeMarkdownDocumentRuns(node, [{ name: "ui", displayName: "UI" }])).toEqual([ + { text: "Use ", role: "body" }, { - type: "paragraph", - children: [{ type: "text", content: "Use $ui for this." }], + text: `${prefix}ui`, + role: "body", + skillName: "ui", + skillLabel: "UI", }, - ], - }; - - expect(nativeMarkdownDocumentRuns(node, [{ name: "ui", displayName: "UI" }])).toEqual([ - { text: "Use ", role: "body" }, - { - text: "$ui", - role: "body", - skillName: "ui", - skillLabel: "UI", - }, - { text: " for this.", role: "body" }, - ]); - }); + { text: " for this.", role: "body" }, + ]); + }, + ); it("decorates known skill references that begin with a digit", () => { const node: MarkdownNode = { diff --git a/apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts index e6e6da9d5e9e152e3cebe08a1a3a0647bdcb0a57..acb7809c3a0d00b7f189ec351774b31fe5251f5a 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts @@ -43,13 +43,40 @@ describe("planClaudeSkillDispatch", () => { }); }); + it("dispatches currency-prefixed mentions and preserves their source boundaries", () => { + for (const symbol of ["€", "£", "¥", "₹", "₩", "₿", "𑿝"]) { + expect( + planClaudeSkillDispatch( + `${symbol}review the diff, then ${symbol}implement the fixes`, + SKILLS, + ), + ).toEqual({ + leadingText: "/review the diff, then", + commandText: "/implement the fixes", + skillName: "implement", + }); + expect(planClaudeSkillDispatch(`${symbol}2spec for this`, SKILLS)).toEqual({ + leadingText: undefined, + commandText: "/2spec for this", + skillName: "2spec", + }); + expect(planClaudeSkillDispatch(`5${symbol}review ${symbol}unknown`, SKILLS)).toBeUndefined(); + } + }); + it("ignores a dollar token glued to other text", () => { expect(planClaudeSkillDispatch("cost is 5$implement", SKILLS)).toBeUndefined(); }); it("ignores currency amounts and compact monetary expressions", () => { - const skillsWithCurrency = new Set([...SKILLS, "20", "20k", "100M"]); - expect(planClaudeSkillDispatch("pay $20 tomorrow", skillsWithCurrency)).toBeUndefined(); - expect(planClaudeSkillDispatch("budget is $20k tomorrow", skillsWithCurrency)).toBeUndefined(); + const skillsWithCurrency = new Set([...SKILLS, "20", "20k", "100M", "1e6"]); + for (const symbol of ["$", "€", "£", "¥", "₹", "₩", "₿", "𑿝"]) { + expect( + planClaudeSkillDispatch( + `pay ${symbol}20 ${symbol}20k ${symbol}100M ${symbol}1e6 tomorrow`, + skillsWithCurrency, + ), + ).toBeUndefined(); + } }); }); diff --git a/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts index 27e7dcf68bdf7d2b49dd488d9a1fcdcdc5da1149..2dc106e853828e3972ae859ed7cd6ac6cf204cb5 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts @@ -30,7 +30,7 @@ * dispatched skill are always the same set. */ const SKILL_MENTION_PATTERN = - /(^|\s)\$(?![0-9][0-9_]*(?:[kKmMbBtT]|[eE][0-9]+)?(?:\s|$))(?=[a-zA-Z0-9:_-]*[a-zA-Z])([a-zA-Z0-9][a-zA-Z0-9:_-]*)(?=\s|$)/g; + /(^|\s)\p{Sc}(?![0-9][0-9_]*(?:[kKmMbBtT]|[eE][0-9]+)?(?:\s|$))(?=[a-zA-Z0-9:_-]*[a-zA-Z])([a-zA-Z0-9][a-zA-Z0-9:_-]*)(?=\s|$)/gu; export interface ClaudeSkillDispatch { /** Text before the dispatched mention, or `undefined` when it opens the prompt. */ @@ -54,7 +54,7 @@ export function planClaudeSkillDispatch( const name = match[2] ?? ""; if (!skillNames.has(name)) return []; const start = (match.index ?? 0) + (match[1]?.length ?? 0); - return [{ name, start, end: start + name.length + 1 }]; + return [{ name, start, end: (match.index ?? 0) + match[0].length }]; }); const last = mentions.at(-1); if (!last) { @@ -66,7 +66,8 @@ export function planClaudeSkillDispatch( const leadingWithInlineSlashes = mentions .slice(0, -1) .reduceRight( - (text, mention) => `${text.slice(0, mention.start)}/${text.slice(mention.start + 1)}`, + (text, mention) => + `${text.slice(0, mention.start)}/${mention.name}${text.slice(mention.end)}`, leading, ) .trimEnd(); diff --git a/apps/server/src/provider/Drivers/CursorSkills.ts b/apps/server/src/provider/Drivers/CursorSkills.ts index 7f8d96d98811d9a900004e2e807cb0f9ea3ec1a6..0562f4d1b729d8bd4d9491d14a60aafac647e152 100644 --- a/apps/server/src/provider/Drivers/CursorSkills.ts +++ b/apps/server/src/provider/Drivers/CursorSkills.ts @@ -20,8 +20,8 @@ import { parse as parseYamlDocument } from "yaml"; const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/; const SKILL_MENTION_PATTERN = - /(^|\s)\$(?![0-9][0-9_]*(?:[kKmMbBtT]|[eE][0-9]+)?(?:\s|$))(?=[a-zA-Z0-9:_-]*[a-zA-Z])([a-zA-Z0-9][a-zA-Z0-9:_-]*)(?=\s|$)/g; -const HAS_SKILL_MENTION_PATTERN = new RegExp(SKILL_MENTION_PATTERN.source); + /(^|\s)\p{Sc}(?![0-9][0-9_]*(?:[kKmMbBtT]|[eE][0-9]+)?(?:\s|$))(?=[a-zA-Z0-9:_-]*[a-zA-Z])([a-zA-Z0-9][a-zA-Z0-9:_-]*)(?=\s|$)/gu; +const HAS_SKILL_MENTION_PATTERN = new RegExp(SKILL_MENTION_PATTERN.source, "u"); const MAX_SKILL_DEPTH = 10; const MAX_SKILL_BYTES = FileSystem.Size(1_000_000); const MAX_SKILL_SCAN_ENTRIES = 10_000; diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index c6257221c73733cd3be8f8fd5f4735a0ac8bd0f8..c14dca7ccd1918584a8dac80c9754f70fb5c2af0 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -154,6 +154,23 @@ function makeThreadOpenResponse( } describe("buildTurnStartParams", () => { + it.effect("sends currency skill aliases in Codex's canonical dollar form", () => + Effect.gen(function* () { + for (const symbol of ["€", "£", "¥", "₹", "₩", "₿", "𑿝"]) { + const prose = `${symbol}20 ${symbol}20k ${symbol}100M ${symbol}1e6 5${symbol}review`; + const params = yield* buildTurnStartParams({ + threadId: "provider-thread-1", + runtimeMode: "full-access", + prompt: `${symbol}review ${symbol}2spec $existing ${prose} ${symbol}last`, + }); + + NodeAssert.deepEqual(params.input, [ + { type: "text", text: `$review $2spec $existing ${prose} $last` }, + ]); + } + }), + ); + it("keeps invalid turn values only in the schema cause", () => { const secret = "codex-turn-input-secret-sentinel"; const error = Effect.runSync( diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index a04db1405912bab73d7af3a134126cb2ecef9bc4..c26d6ae446d8c7a2784c853a4251b9a3993a45f8 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -605,6 +605,10 @@ function buildCodexCollaborationMode(input: { }; } +// Match the skill grammar used by Claude/Cursor, leaving currency amounts as prose. +const SKILL_MENTION_PATTERN = + /(^|\s)\p{Sc}(?![0-9][0-9_]*(?:[kKmMbBtT]|[eE][0-9]+)?(?:\s|$))(?=[a-zA-Z0-9:_-]*[a-zA-Z])([a-zA-Z0-9][a-zA-Z0-9:_-]*)(?=\s|$)/gu; + export function buildTurnStartParams(input: { readonly threadId: string; readonly runtimeMode: RuntimeMode; @@ -627,7 +631,7 @@ export function buildTurnStartParams(input: { if (input.prompt) { turnInput.push({ type: "text", - text: input.prompt, + text: input.prompt.replace(SKILL_MENTION_PATTERN, "$1$$$2"), }); } for (const attachment of input.attachments ?? []) { diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index 04e46af678308bcd1f200192a8a6deb04b46c00d..19cfd22c6964121a69b035cd3a4dc80e49ec5258 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -461,6 +461,22 @@ describe("Cursor skills", () => { ); }); + it("rewrites currency-prefixed skill mentions into Cursor slash invocations", () => { + const names = new Set(["review", "2spec", "20k", "100M", "1e6"]); + for (const symbol of ["€", "£", "¥", "₹", "₩", "₿", "𑿝"]) { + expect(hasCursorSkillMention(`please ${symbol}review this`)).toBe(true); + expect(hasCursorSkillMention(`please ${symbol}review this`)).toBe(true); + expect(rewriteCursorSkillMentions(`${symbol}review then ${symbol}2spec this`, names)).toBe( + "/review then /2spec this", + ); + const money = `${symbol}20 ${symbol}20k ${symbol}100M ${symbol}1e6`; + expect(hasCursorSkillMention(money)).toBe(false); + expect(rewriteCursorSkillMentions(money, names)).toBe(money); + const prose = `5${symbol}review ${symbol}unknown`; + expect(rewriteCursorSkillMentions(prose, names)).toBe(prose); + } + }); + it("detects and invokes digit-leading Cursor skills without rewriting money", () => { const names = new Set(["2spec", "20k", "100M", "1e6"]); // Repeated presence checks must not carry a global-regex cursor. diff --git a/apps/web/src/components/chat/SkillInlineText.tsx b/apps/web/src/components/chat/SkillInlineText.tsx index 160204ee094f50afeb6b638663f20fc42ffcca48..83930ea2d6009419a728c8816a837665e3ff5b28 100644 --- a/apps/web/src/components/chat/SkillInlineText.tsx +++ b/apps/web/src/components/chat/SkillInlineText.tsx @@ -12,7 +12,7 @@ import { import { cn } from "~/lib/utils"; const SKILL_TOKEN_REGEX = - /(^|\s)\$(?![0-9][0-9_]*(?:[kKmMbBtT]|[eE][0-9]+)?(?:\s|$))(?=[a-zA-Z0-9:_-]*[a-zA-Z])([a-zA-Z0-9][a-zA-Z0-9:_-]*)(?=\s|$)/g; + /(^|\s)\p{Sc}(?![0-9][0-9_]*(?:[kKmMbBtT]|[eE][0-9]+)?(?:\s|$))(?=[a-zA-Z0-9:_-]*[a-zA-Z])([a-zA-Z0-9][a-zA-Z0-9:_-]*)(?=\s|$)/gu; type InlineSkill = Pick; @@ -34,7 +34,7 @@ export function SkillInlineText(props: { text: string; skills: ReadonlyArray); - cursor = start + rawText.length; + cursor = (match.index ?? 0) + match[0].length; } if (cursor === 0) { diff --git a/apps/web/src/composer-editor-mentions.test.ts b/apps/web/src/composer-editor-mentions.test.ts index 7479a8439b61d43d5f59e1e4d90f87b361ddd7d4..0ce166a29ef2739093d050ee5b8fe4efb0ed5a99 100644 --- a/apps/web/src/composer-editor-mentions.test.ts +++ b/apps/web/src/composer-editor-mentions.test.ts @@ -154,7 +154,7 @@ describe("splitPromptIntoComposerSegments", () => { { type: "text", text: " " }, { type: "citation", citation, source }, { type: "text", text: "\n" }, - { type: "skill", name: "review" }, + { type: "skill", name: "review", source: "$review" }, { type: "text", text: " " }, { type: "context-reference", @@ -217,7 +217,7 @@ describe("splitPromptIntoComposerSegments", () => { it("splits skill tokens followed by whitespace into skill segments", () => { expect(splitPromptIntoComposerSegments("Use $review-follow-up please")).toEqual([ { type: "text", text: "Use " }, - { type: "skill", name: "review-follow-up" }, + { type: "skill", name: "review-follow-up", source: "$review-follow-up" }, { type: "text", text: " please" }, ]); }); @@ -225,7 +225,7 @@ describe("splitPromptIntoComposerSegments", () => { it("splits digit-leading skill tokens into skill segments", () => { expect(splitPromptIntoComposerSegments("Use $2spec please")).toEqual([ { type: "text", text: "Use " }, - { type: "skill", name: "2spec" }, + { type: "skill", name: "2spec", source: "$2spec" }, { type: "text", text: " please" }, ]); }); @@ -278,7 +278,7 @@ describe("splitPromptIntoComposerSegments", () => { { type: "text", text: "Inspect " }, terminalSegment, { type: "text", text: " " }, - { type: "skill", name: "review-follow-up" }, + { type: "skill", name: "review-follow-up", source: "$review-follow-up" }, { type: "text", text: " after " }, { type: "mention", path: "AGENTS.md", source: "@AGENTS.md" }, { type: "text", text: " " }, diff --git a/apps/web/src/composer-editor-mentions.ts b/apps/web/src/composer-editor-mentions.ts index 879fcdc508f2781f8ee32d862fc57cb2f38c72fe..e228c968d915bb2cf8c77d572243ced1edac0da7 100644 --- a/apps/web/src/composer-editor-mentions.ts +++ b/apps/web/src/composer-editor-mentions.ts @@ -19,6 +19,7 @@ export type ComposerPromptSegment = | { type: "skill"; name: string; + source: string; } | { type: "citation"; @@ -127,7 +128,7 @@ function splitPromptTextIntoComposerSegments(text: string): ComposerPromptSegmen source: match.source, }); } else { - segments.push({ type: "skill", name: match.value }); + segments.push({ type: "skill", name: match.value, source: match.source }); } cursor = match.end; diff --git a/apps/web/src/composer-list-continuation.test.ts b/apps/web/src/composer-list-continuation.test.ts index ec56dfaacf7854134db6d604d45e39e90a732927..b92514dd2714cf192d671659b150be0b94ef5a05 100644 --- a/apps/web/src/composer-list-continuation.test.ts +++ b/apps/web/src/composer-list-continuation.test.ts @@ -48,6 +48,12 @@ describe("composer list continuation", () => { expect(listContinuationForEnter("1. foo", 1)).toBeNull(); }); + it("refuses to split a supplementary currency skill chip", () => { + const value = "1. 𑿝review go"; + const cursor = value.indexOf(" go") - 1; + expect(listContinuationForEnter(value, cursor)).toBeNull(); + }); + it("refuses to split an inline chip", () => { const value = "1. @README.md go"; const cursor = value.indexOf("README") + 2; diff --git a/apps/web/src/composer-list-continuation.ts b/apps/web/src/composer-list-continuation.ts index 0672bbc1d27fff50dea976d4748f4113ff66cad9..952dfa549475d22ff497b224fbb814e5f1ecd15f 100644 --- a/apps/web/src/composer-list-continuation.ts +++ b/apps/web/src/composer-list-continuation.ts @@ -73,7 +73,6 @@ function segmentSource( segment: ReturnType[number], ): string { if (segment.type === "text") return segment.text; - if (segment.type === "skill") return `$${segment.name}`; return segment.source; } diff --git a/apps/web/src/composer-logic.test.ts b/apps/web/src/composer-logic.test.ts index 64ff906d41d1b3fd91c92757214d1dae96b78acd..d4509516cba7e0fea571dc26ca42e5b5d5ef0e2d 100644 --- a/apps/web/src/composer-logic.test.ts +++ b/apps/web/src/composer-logic.test.ts @@ -230,17 +230,20 @@ describe("detectComposerTrigger", () => { }); }); - it("detects $skill trigger at cursor", () => { - const text = "Use $gh-fi"; - const trigger = detectComposerTrigger(text, text.length); + it.each(["$", "€", "£", "¥", "₹", "₩", "₿", "𑿝"])( + "detects %sskill trigger at cursor", + (prefix) => { + const text = `Use ${prefix}gh-fi`; + const trigger = detectComposerTrigger(text, text.length); - expect(trigger).toEqual({ - kind: "skill", - query: "gh-fi", - rangeStart: "Use ".length, - rangeEnd: text.length, - }); - }); + expect(trigger).toEqual({ + kind: "skill", + query: "gh-fi", + rangeStart: "Use ".length, + rangeEnd: text.length, + }); + }, + ); it("detects a pull request number at a token boundary", () => { const text = "Compare this with #8737"; @@ -468,10 +471,10 @@ describe("expandCollapsedComposerCursor", () => { expect(detectComposerTrigger(text, expandedCursor)).toBeNull(); }); - it("maps collapsed skill cursor to expanded text cursor", () => { - const text = "run $review-follow-up then"; + it.each(["$", "€", "𑿝"])("maps collapsed %s skill cursor to expanded text cursor", (prefix) => { + const text = `run ${prefix}review-follow-up then`; const collapsedCursorAfterSkill = "run ".length + 2; - const expandedCursorAfterSkill = "run $review-follow-up ".length; + const expandedCursorAfterSkill = `run ${prefix}review-follow-up `.length; expect(expandCollapsedComposerCursor(text, collapsedCursorAfterSkill)).toBe( expandedCursorAfterSkill, @@ -532,10 +535,10 @@ describe("collapseExpandedComposerCursor", () => { expect(expandCollapsedComposerCursor(text, collapsedCursor)).toBe(expandedCursor); }); - it("maps expanded skill cursor back to collapsed cursor", () => { - const text = "run $review-follow-up then"; + it.each(["$", "€", "𑿝"])("maps expanded %s skill cursor back to collapsed cursor", (prefix) => { + const text = `run ${prefix}review-follow-up then`; const collapsedCursorAfterSkill = "run ".length + 2; - const expandedCursorAfterSkill = "run $review-follow-up ".length; + const expandedCursorAfterSkill = `run ${prefix}review-follow-up `.length; expect(collapseExpandedComposerCursor(text, expandedCursorAfterSkill)).toBe( collapsedCursorAfterSkill, @@ -543,6 +546,13 @@ describe("collapseExpandedComposerCursor", () => { }); }); +it("preserves the caret before trailing text after mixed-width skill chips", () => { + const text = "𑿝ui a $review tail"; + const expanded = "𑿝ui a $review ".length; + expect(collapseExpandedComposerCursor(text, expanded)).toBe(6); + expect(expandCollapsedComposerCursor(text, 6)).toBe(expanded); +}); + describe("clampCollapsedComposerCursor", () => { it("clamps to collapsed prompt length when mentions are present", () => { const text = "open @AGENTS.md then "; diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index ed74adf51db15045c8621c72552900a23c4688d6..a2ed12b86b9ebe638a260684aa9c2ae46e03f0c7 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -88,7 +88,7 @@ export function expandCollapsedComposerCursor(text: string, cursorInput: number) continue; } if (segment.type === "skill") { - const expandedLength = segment.name.length + 1; + const expandedLength = segment.source.length; if (remaining <= 1) { return expandedCursor + (remaining === 0 ? 0 : expandedLength); } @@ -164,7 +164,7 @@ export function collapseExpandedComposerCursor(text: string, cursorInput: number continue; } if (segment.type === "skill") { - const expandedLength = segment.name.length + 1; + const expandedLength = segment.source.length; if (remaining === 0) { return collapsedCursor; } @@ -244,10 +244,11 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos rangeEnd: cursor, }; } - if (token.startsWith("$")) { + const skillPrefix = /^\p{Sc}/u.exec(token); + if (skillPrefix) { return { kind: "skill", - query: token.slice(1), + query: token.slice(skillPrefix[0].length), rangeStart: tokenStart, rangeEnd: cursor, }; diff --git a/apps/web/src/composer-rich-text-doc.test.ts b/apps/web/src/composer-rich-text-doc.test.ts index 15c8db1e41fe2ff95b9c8afd25eb0595fc009fb4..a8798708974b2e19913bd7e7b42be4d18d10b99c 100644 --- a/apps/web/src/composer-rich-text-doc.test.ts +++ b/apps/web/src/composer-rich-text-doc.test.ts @@ -118,6 +118,16 @@ function roundTripPlain(value: string) { } describe("composer rich text document model", () => { + it.each(["€", "£", "¥", "₹", "₩", "₿", "𑿝"])( + "canonicalizes %s skill aliases while preserving amounts", + (prefix) => { + const value = `Use ${prefix}my-skill for ${prefix}20 please`; + const expected = `Use $my-skill for ${prefix}20 please`; + expect(roundTrip(value).value).toBe(expected); + expect(roundTripPlain(value).value).toBe(expected); + }, + ); + it.each([ "", "\n\n", diff --git a/apps/web/src/providerSkillSearch.test.ts b/apps/web/src/providerSkillSearch.test.ts index 01059d6148fafb093139e97e3d49e49cabdc48c0..b02cd2805c2e9d2f9dc43c6f665140ce7822d6c6 100644 --- a/apps/web/src/providerSkillSearch.test.ts +++ b/apps/web/src/providerSkillSearch.test.ts @@ -13,6 +13,14 @@ function makeSkill(input: Partial & Pick { + it.each(["$", "€", "£", "¥", "₹", "₩", "₿", "𑿝"])( + "searches skills with a %s prefix", + (prefix) => { + const skill = makeSkill({ name: "review" }); + expect(searchProviderSkills([skill], `${prefix}review`)).toEqual([skill]); + }, + ); + it("moves exact ui matches ahead of broader ui matches", () => { const skills = [ makeSkill({ diff --git a/apps/web/src/providerSkillSearch.ts b/apps/web/src/providerSkillSearch.ts index 21dd2874480b70afc339528e14d654fd6a5e9866..a570c92d91f1454fcc8238b88e4ec1540a70986e 100644 --- a/apps/web/src/providerSkillSearch.ts +++ b/apps/web/src/providerSkillSearch.ts @@ -75,7 +75,7 @@ export function searchProviderSkills( limit = Number.POSITIVE_INFINITY, ): ServerProviderSkill[] { const enabledSkills = dedupeProviderSkillsByName(skills.filter(isProviderSkillUserInvocable)); - const normalizedQuery = normalizeSearchQuery(query, { trimLeadingPattern: /^\$+/ }); + const normalizedQuery = normalizeSearchQuery(query, { trimLeadingPattern: /^\p{Sc}+/u }); if (!normalizedQuery) { return enabledSkills; diff --git a/packages/shared/src/composerInlineTokens.test.ts b/packages/shared/src/composerInlineTokens.test.ts index 56243c2d84e55c0763428ac3895f61c62735d5a9..5f19fb2a99893d1bc82f77a08b7beb915b71b554 100644 --- a/packages/shared/src/composerInlineTokens.test.ts +++ b/packages/shared/src/composerInlineTokens.test.ts @@ -31,16 +31,27 @@ describe("collectComposerInlineTokens", () => { ]); }); - it("collects skill names that begin with a digit", () => { - expect(collectComposerInlineTokens("Use $2spec next")).toEqual([ - { - type: "skill", - value: "2spec", - source: "$2spec", - start: 4, - end: 10, - }, - ]); + it.each(["$", "€", "£", "¥", "₹", "₩", "₿", "𑿝"])( + "collects %s skill names that begin with a digit", + (prefix) => { + expect(collectComposerInlineTokens(`Use ${prefix}2spec next`)).toEqual([ + { + type: "skill", + value: "2spec", + source: `${prefix}2spec`, + start: 4, + end: 9 + prefix.length, + }, + ]); + }, + ); + + it.each(["€", "£", "¥", "₹", "₩", "₿", "𑿝"])("leaves %s currency amounts as text", (prefix) => { + expect( + collectComposerInlineTokens( + `${prefix}20 ${prefix}1_000 ${prefix}20k ${prefix}100M ${prefix}1e6 `, + ), + ).toEqual([]); }); it("leaves digits-only dollar amounts and compact monetary expressions as text", () => { diff --git a/packages/shared/src/composerInlineTokens.ts b/packages/shared/src/composerInlineTokens.ts index bb39dd59947509025b35041d617fa61bedffe932..e7f57f260aea8d8a9c9205a345cacc0d66c8f1da 100644 --- a/packages/shared/src/composerInlineTokens.ts +++ b/packages/shared/src/composerInlineTokens.ts @@ -26,7 +26,7 @@ export interface CollectComposerInlineTokensOptions { * contain at least one letter. */ const SKILL_TOKEN_REGEX = - /(^|\s)\$(?![0-9][0-9_]*(?:[kKmMbBtT]|[eE][0-9]+)?(?:\s|$))(?=[a-zA-Z0-9:_-]*[a-zA-Z])([a-zA-Z0-9][a-zA-Z0-9:_-]*)(?=\s)/g; + /(^|\s)\p{Sc}(?![0-9][0-9_]*(?:[kKmMbBtT]|[eE][0-9]+)?(?:\s|$))(?=[a-zA-Z0-9:_-]*[a-zA-Z])([a-zA-Z0-9][a-zA-Z0-9:_-]*)(?=\s)/gu; const MENTION_TOKEN_REGEX = /(^|\s)@(?:"((?:\\.|[^"\\])*)"|([^\s@"]+))(?=\s)/g; /** * The label body is bounded rather than `*`. Unbounded, every whitespace in diff --git a/packages/shared/src/composerTrigger.test.ts b/packages/shared/src/composerTrigger.test.ts index 4b27638544578b50fd57790c29d808dc129df0c0..06b7038dad2b23824fb7ad9381ea3c4163e586b8 100644 --- a/packages/shared/src/composerTrigger.test.ts +++ b/packages/shared/src/composerTrigger.test.ts @@ -1,6 +1,21 @@ import { describe, expect, it } from "vite-plus/test"; -import { serializeComposerFileLink } from "./composerTrigger.ts"; +import { detectComposerTrigger, serializeComposerFileLink } from "./composerTrigger.ts"; + +describe("detectComposerTrigger", () => { + it.each(["$", "€", "£", "¥", "₹", "₩", "₿", "𑿝"])( + "detects %s skill prefixes and their source range", + (prefix) => { + const text = `Use ${prefix}review`; + expect(detectComposerTrigger(text, text.length)).toEqual({ + kind: "skill", + query: "review", + rangeStart: 4, + rangeEnd: text.length, + }); + }, + ); +}); describe("serializeComposerFileLink", () => { it("uses the basename as the markdown label", () => { diff --git a/packages/shared/src/composerTrigger.ts b/packages/shared/src/composerTrigger.ts index 46e56dacfcd2e3ea64271f2b0a3e2f0bb50f289f..6153978edb92a931a8a76ef7327f5e717935c1db 100644 --- a/packages/shared/src/composerTrigger.ts +++ b/packages/shared/src/composerTrigger.ts @@ -108,10 +108,11 @@ export function detectComposerTrigger( rangeStart: tokenStart, rangeEnd: cursor, }; - if (token.startsWith("$")) { + const skillPrefix = /^\p{Sc}/u.exec(token); + if (skillPrefix) { return { kind: "skill", - query: token.slice(1), + query: token.slice(skillPrefix[0].length), rangeStart: tokenStart, rangeEnd: cursor, }; -- 2.54.0