authorgravatar for wilgot10@yahoo.comWilgot <wilgot10@yahoo.com> 2026-09-17 06:19:50+01:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-17 02:19:50-03:00
log2a47fc905a7a1054823919980f625b7cd869f1c1
tree523915eff1592ee49b21ecb01f47438afa699411
parent01eb4b9a8776e6057dfacf12e7366565e5eab934
signature Signed by PGP key B5690EEEBB952194

fix(skills): support unicode currency symbols as skill aliases (#12098)

Co-authored-by: maria-rcks <maria@kuuro.net>

24 files changed, 209 insertions(+), 78 deletions(-)

apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts+2-2
......@@ -319,7 +319,7 @@ function appendRun(
319319}
320320
321321const SKILL_TOKEN_REGEX =
322 /(^|\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;
322 /(^|\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;
323323
324324function formatSkillLabel(skill: SelectableMarkdownSkill): string {
325325 const displayName = skill.displayName?.trim();
......@@ -359,7 +359,7 @@ function decorateSkillRuns(
359359 continue;
360360 }
361361 const start = (match.index ?? 0) + prefix.length;
362 const end = start + name.length + 1;
362 const end = (match.index ?? 0) + match[0].length;
363363 if (start > cursor) {
364364 decorated.push({ ...run, text: run.text.slice(cursor, start) });
365365 }
apps/mobile/src/components/ComposerEditor.tsx+3-2
......@@ -155,8 +155,9 @@ export function ComposerEditor({
155155 const selectedReference = selected
156156 ? collectComposerContextReferences(selected.source)[0]
157157 : undefined;
158 const selectedSkill = selected?.source.startsWith("$")
159 ? props.skills?.find((skill) => skill.name === selected.source.slice(1))
158 const selectedSkillName = selected?.source.match(/^\p{Sc}(.+)$/u)?.[1];
159 const selectedSkill = selectedSkillName
160 ? props.skills?.find((skill) => skill.name === selectedSkillName)
160161 : undefined;
161162 const record = draft.context?.records.find(
162163 (entry) => entry.contextId === selectedReference?.contextId,
apps/mobile/src/features/threads/use-composer-command-menu.ts+1-1
......@@ -366,7 +366,7 @@ export function useComposerCommandMenu({
366366 if (trigger.kind === "skill") {
367367 const enabledSkills = dedupeProviderSkillsByName(skills.filter(isProviderSkillUserInvocable));
368368 const normalizedQuery = normalizeSearchQuery(trigger.query, {
369 trimLeadingPattern: /^\$+/,
369 trimLeadingPattern: /^\p{Sc}+/u,
370370 });
371371
372372 if (!normalizedQuery) {
apps/mobile/src/lib/nativeMarkdownText.test.ts+23-20
......@@ -307,28 +307,31 @@ describe("nativeMarkdownDocumentRuns", () => {
307307 ]);
308308 });
309309
310 it("decorates known skill references as selectable skill links", () => {
311 const node: MarkdownNode = {
312 type: "document",
313 children: [
310 it.each(["$", "€", "£", "¥", "₹", "₩", "₿", "𑿝"])(
311 "decorates %s skill references as selectable skill links",
312 (prefix) => {
313 const node: MarkdownNode = {
314 type: "document",
315 children: [
316 {
317 type: "paragraph",
318 children: [{ type: "text", content: `Use ${prefix}ui for this.` }],
319 },
320 ],
321 };
322
323 expect(nativeMarkdownDocumentRuns(node, [{ name: "ui", displayName: "UI" }])).toEqual([
324 { text: "Use ", role: "body" },
314325 {
315 type: "paragraph",
316 children: [{ type: "text", content: "Use $ui for this." }],
326 text: `${prefix}ui`,
327 role: "body",
328 skillName: "ui",
329 skillLabel: "UI",
317330 },
318 ],
319 };
320
321 expect(nativeMarkdownDocumentRuns(node, [{ name: "ui", displayName: "UI" }])).toEqual([
322 { text: "Use ", role: "body" },
323 {
324 text: "$ui",
325 role: "body",
326 skillName: "ui",
327 skillLabel: "UI",
328 },
329 { text: " for this.", role: "body" },
330 ]);
331 });
331 { text: " for this.", role: "body" },
332 ]);
333 },
334 );
332335
333336 it("decorates known skill references that begin with a digit", () => {
334337 const node: MarkdownNode = {
apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts+30-3
......@@ -43,13 +43,40 @@ describe("planClaudeSkillDispatch", () => {
4343 });
4444 });
4545
46 it("dispatches currency-prefixed mentions and preserves their source boundaries", () => {
47 for (const symbol of ["€", "£", "¥", "₹", "₩", "₿", "𑿝"]) {
48 expect(
49 planClaudeSkillDispatch(
50 `${symbol}review the diff, then ${symbol}implement the fixes`,
51 SKILLS,
52 ),
53 ).toEqual({
54 leadingText: "/review the diff, then",
55 commandText: "/implement the fixes",
56 skillName: "implement",
57 });
58 expect(planClaudeSkillDispatch(`${symbol}2spec for this`, SKILLS)).toEqual({
59 leadingText: undefined,
60 commandText: "/2spec for this",
61 skillName: "2spec",
62 });
63 expect(planClaudeSkillDispatch(`5${symbol}review ${symbol}unknown`, SKILLS)).toBeUndefined();
64 }
65 });
66
4667 it("ignores a dollar token glued to other text", () => {
4768 expect(planClaudeSkillDispatch("cost is 5$implement", SKILLS)).toBeUndefined();
4869 });
4970
5071 it("ignores currency amounts and compact monetary expressions", () => {
51 const skillsWithCurrency = new Set([...SKILLS, "20", "20k", "100M"]);
52 expect(planClaudeSkillDispatch("pay $20 tomorrow", skillsWithCurrency)).toBeUndefined();
53 expect(planClaudeSkillDispatch("budget is $20k tomorrow", skillsWithCurrency)).toBeUndefined();
72 const skillsWithCurrency = new Set([...SKILLS, "20", "20k", "100M", "1e6"]);
73 for (const symbol of ["$", "€", "£", "¥", "₹", "₩", "₿", "𑿝"]) {
74 expect(
75 planClaudeSkillDispatch(
76 `pay ${symbol}20 ${symbol}20k ${symbol}100M ${symbol}1e6 tomorrow`,
77 skillsWithCurrency,
78 ),
79 ).toBeUndefined();
80 }
5481 });
5582});
apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts+4-3
......@@ -30,7 +30,7 @@
3030 * dispatched skill are always the same set.
3131 */
3232const SKILL_MENTION_PATTERN =
33 /(^|\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;
33 /(^|\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;
3434
3535export interface ClaudeSkillDispatch {
3636 /** Text before the dispatched mention, or `undefined` when it opens the prompt. */
......@@ -54,7 +54,7 @@ export function planClaudeSkillDispatch(
5454 const name = match[2] ?? "";
5555 if (!skillNames.has(name)) return [];
5656 const start = (match.index ?? 0) + (match[1]?.length ?? 0);
57 return [{ name, start, end: start + name.length + 1 }];
57 return [{ name, start, end: (match.index ?? 0) + match[0].length }];
5858 });
5959 const last = mentions.at(-1);
6060 if (!last) {
......@@ -66,7 +66,8 @@ export function planClaudeSkillDispatch(
6666 const leadingWithInlineSlashes = mentions
6767 .slice(0, -1)
6868 .reduceRight(
69 (text, mention) => `${text.slice(0, mention.start)}/${text.slice(mention.start + 1)}`,
69 (text, mention) =>
70 `${text.slice(0, mention.start)}/${mention.name}${text.slice(mention.end)}`,
7071 leading,
7172 )
7273 .trimEnd();
apps/server/src/provider/Drivers/CursorSkills.ts+2-2
......@@ -20,8 +20,8 @@ import { parse as parseYamlDocument } from "yaml";
2020
2121const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
2222const SKILL_MENTION_PATTERN =
23 /(^|\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;
24const HAS_SKILL_MENTION_PATTERN = new RegExp(SKILL_MENTION_PATTERN.source);
23 /(^|\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;
24const HAS_SKILL_MENTION_PATTERN = new RegExp(SKILL_MENTION_PATTERN.source, "u");
2525const MAX_SKILL_DEPTH = 10;
2626const MAX_SKILL_BYTES = FileSystem.Size(1_000_000);
2727const MAX_SKILL_SCAN_ENTRIES = 10_000;
apps/server/src/provider/Layers/CodexSessionRuntime.test.ts+17
......@@ -154,6 +154,23 @@ function makeThreadOpenResponse(
154154}
155155
156156describe("buildTurnStartParams", () => {
157 it.effect("sends currency skill aliases in Codex's canonical dollar form", () =>
158 Effect.gen(function* () {
159 for (const symbol of ["€", "£", "¥", "₹", "₩", "₿", "𑿝"]) {
160 const prose = `${symbol}20 ${symbol}20k ${symbol}100M ${symbol}1e6 5${symbol}review`;
161 const params = yield* buildTurnStartParams({
162 threadId: "provider-thread-1",
163 runtimeMode: "full-access",
164 prompt: `${symbol}review ${symbol}2spec $existing ${prose} ${symbol}last`,
165 });
166
167 NodeAssert.deepEqual(params.input, [
168 { type: "text", text: `$review $2spec $existing ${prose} $last` },
169 ]);
170 }
171 }),
172 );
173
157174 it("keeps invalid turn values only in the schema cause", () => {
158175 const secret = "codex-turn-input-secret-sentinel";
159176 const error = Effect.runSync(
apps/server/src/provider/Layers/CodexSessionRuntime.ts+5-1
......@@ -605,6 +605,10 @@ function buildCodexCollaborationMode(input: {
605605 };
606606}
607607
608// Match the skill grammar used by Claude/Cursor, leaving currency amounts as prose.
609const SKILL_MENTION_PATTERN =
610 /(^|\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;
611
608612export function buildTurnStartParams(input: {
609613 readonly threadId: string;
610614 readonly runtimeMode: RuntimeMode;
......@@ -627,7 +631,7 @@ export function buildTurnStartParams(input: {
627631 if (input.prompt) {
628632 turnInput.push({
629633 type: "text",
630 text: input.prompt,
634 text: input.prompt.replace(SKILL_MENTION_PATTERN, "$1$$$2"),
631635 });
632636 }
633637 for (const attachment of input.attachments ?? []) {
apps/server/src/provider/Layers/CursorProvider.test.ts+16
......@@ -461,6 +461,22 @@ describe("Cursor skills", () => {
461461 );
462462 });
463463
464 it("rewrites currency-prefixed skill mentions into Cursor slash invocations", () => {
465 const names = new Set(["review", "2spec", "20k", "100M", "1e6"]);
466 for (const symbol of ["€", "£", "¥", "₹", "₩", "₿", "𑿝"]) {
467 expect(hasCursorSkillMention(`please ${symbol}review this`)).toBe(true);
468 expect(hasCursorSkillMention(`please ${symbol}review this`)).toBe(true);
469 expect(rewriteCursorSkillMentions(`${symbol}review then ${symbol}2spec this`, names)).toBe(
470 "/review then /2spec this",
471 );
472 const money = `${symbol}20 ${symbol}20k ${symbol}100M ${symbol}1e6`;
473 expect(hasCursorSkillMention(money)).toBe(false);
474 expect(rewriteCursorSkillMentions(money, names)).toBe(money);
475 const prose = `5${symbol}review ${symbol}unknown`;
476 expect(rewriteCursorSkillMentions(prose, names)).toBe(prose);
477 }
478 });
479
464480 it("detects and invokes digit-leading Cursor skills without rewriting money", () => {
465481 const names = new Set(["2spec", "20k", "100M", "1e6"]);
466482 // Repeated presence checks must not carry a global-regex cursor.
apps/web/src/components/chat/SkillInlineText.tsx+2-2
......@@ -12,7 +12,7 @@ import {
1212import { cn } from "~/lib/utils";
1313
1414const SKILL_TOKEN_REGEX =
15 /(^|\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;
15 /(^|\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;
1616
1717type InlineSkill = Pick<ServerProviderSkill, "name" | "displayName">;
1818
......@@ -34,7 +34,7 @@ export function SkillInlineText(props: { text: string; skills: ReadonlyArray<Inl
3434 nodes.push(props.text.slice(cursor, start));
3535 }
3636 nodes.push(<SkillChip key={`${start}:${name}`} skill={skill} rawText={rawText} />);
37 cursor = start + rawText.length;
37 cursor = (match.index ?? 0) + match[0].length;
3838 }
3939
4040 if (cursor === 0) {
apps/web/src/composer-editor-mentions.test.ts+4-4
......@@ -154,7 +154,7 @@ describe("splitPromptIntoComposerSegments", () => {
154154 { type: "text", text: " " },
155155 { type: "citation", citation, source },
156156 { type: "text", text: "\n" },
157 { type: "skill", name: "review" },
157 { type: "skill", name: "review", source: "$review" },
158158 { type: "text", text: " " },
159159 {
160160 type: "context-reference",
......@@ -217,7 +217,7 @@ describe("splitPromptIntoComposerSegments", () => {
217217 it("splits skill tokens followed by whitespace into skill segments", () => {
218218 expect(splitPromptIntoComposerSegments("Use $review-follow-up please")).toEqual([
219219 { type: "text", text: "Use " },
220 { type: "skill", name: "review-follow-up" },
220 { type: "skill", name: "review-follow-up", source: "$review-follow-up" },
221221 { type: "text", text: " please" },
222222 ]);
223223 });
......@@ -225,7 +225,7 @@ describe("splitPromptIntoComposerSegments", () => {
225225 it("splits digit-leading skill tokens into skill segments", () => {
226226 expect(splitPromptIntoComposerSegments("Use $2spec please")).toEqual([
227227 { type: "text", text: "Use " },
228 { type: "skill", name: "2spec" },
228 { type: "skill", name: "2spec", source: "$2spec" },
229229 { type: "text", text: " please" },
230230 ]);
231231 });
......@@ -278,7 +278,7 @@ describe("splitPromptIntoComposerSegments", () => {
278278 { type: "text", text: "Inspect " },
279279 terminalSegment,
280280 { type: "text", text: " " },
281 { type: "skill", name: "review-follow-up" },
281 { type: "skill", name: "review-follow-up", source: "$review-follow-up" },
282282 { type: "text", text: " after " },
283283 { type: "mention", path: "AGENTS.md", source: "@AGENTS.md" },
284284 { type: "text", text: " " },
apps/web/src/composer-editor-mentions.ts+2-1
......@@ -19,6 +19,7 @@ export type ComposerPromptSegment =
1919 | {
2020 type: "skill";
2121 name: string;
22 source: string;
2223 }
2324 | {
2425 type: "citation";
......@@ -127,7 +128,7 @@ function splitPromptTextIntoComposerSegments(text: string): ComposerPromptSegmen
127128 source: match.source,
128129 });
129130 } else {
130 segments.push({ type: "skill", name: match.value });
131 segments.push({ type: "skill", name: match.value, source: match.source });
131132 }
132133
133134 cursor = match.end;
apps/web/src/composer-list-continuation.test.ts+6
......@@ -48,6 +48,12 @@ describe("composer list continuation", () => {
4848 expect(listContinuationForEnter("1. foo", 1)).toBeNull();
4949 });
5050
51 it("refuses to split a supplementary currency skill chip", () => {
52 const value = "1. 𑿝review go";
53 const cursor = value.indexOf(" go") - 1;
54 expect(listContinuationForEnter(value, cursor)).toBeNull();
55 });
56
5157 it("refuses to split an inline chip", () => {
5258 const value = "1. @README.md go";
5359 const cursor = value.indexOf("README") + 2;
apps/web/src/composer-list-continuation.ts-1
......@@ -73,7 +73,6 @@ function segmentSource(
7373 segment: ReturnType<typeof splitPromptIntoComposerSegments>[number],
7474): string {
7575 if (segment.type === "text") return segment.text;
76 if (segment.type === "skill") return `$${segment.name}`;
7776 return segment.source;
7877}
7978
apps/web/src/composer-logic.test.ts+27-17
......@@ -230,17 +230,20 @@ describe("detectComposerTrigger", () => {
230230 });
231231 });
232232
233 it("detects $skill trigger at cursor", () => {
234 const text = "Use $gh-fi";
235 const trigger = detectComposerTrigger(text, text.length);
236
237 expect(trigger).toEqual({
238 kind: "skill",
239 query: "gh-fi",
240 rangeStart: "Use ".length,
241 rangeEnd: text.length,
242 });
243 });
233 it.each(["$", "€", "£", "¥", "₹", "₩", "₿", "𑿝"])(
234 "detects %sskill trigger at cursor",
235 (prefix) => {
236 const text = `Use ${prefix}gh-fi`;
237 const trigger = detectComposerTrigger(text, text.length);
238
239 expect(trigger).toEqual({
240 kind: "skill",
241 query: "gh-fi",
242 rangeStart: "Use ".length,
243 rangeEnd: text.length,
244 });
245 },
246 );
244247
245248 it("detects a pull request number at a token boundary", () => {
246249 const text = "Compare this with #8737";
......@@ -468,10 +471,10 @@ describe("expandCollapsedComposerCursor", () => {
468471 expect(detectComposerTrigger(text, expandedCursor)).toBeNull();
469472 });
470473
471 it("maps collapsed skill cursor to expanded text cursor", () => {
472 const text = "run $review-follow-up then";
474 it.each(["$", "€", "𑿝"])("maps collapsed %s skill cursor to expanded text cursor", (prefix) => {
475 const text = `run ${prefix}review-follow-up then`;
473476 const collapsedCursorAfterSkill = "run ".length + 2;
474 const expandedCursorAfterSkill = "run $review-follow-up ".length;
477 const expandedCursorAfterSkill = `run ${prefix}review-follow-up `.length;
475478
476479 expect(expandCollapsedComposerCursor(text, collapsedCursorAfterSkill)).toBe(
477480 expandedCursorAfterSkill,
......@@ -532,10 +535,10 @@ describe("collapseExpandedComposerCursor", () => {
532535 expect(expandCollapsedComposerCursor(text, collapsedCursor)).toBe(expandedCursor);
533536 });
534537
535 it("maps expanded skill cursor back to collapsed cursor", () => {
536 const text = "run $review-follow-up then";
538 it.each(["$", "€", "𑿝"])("maps expanded %s skill cursor back to collapsed cursor", (prefix) => {
539 const text = `run ${prefix}review-follow-up then`;
537540 const collapsedCursorAfterSkill = "run ".length + 2;
538 const expandedCursorAfterSkill = "run $review-follow-up ".length;
541 const expandedCursorAfterSkill = `run ${prefix}review-follow-up `.length;
539542
540543 expect(collapseExpandedComposerCursor(text, expandedCursorAfterSkill)).toBe(
541544 collapsedCursorAfterSkill,
......@@ -543,6 +546,13 @@ describe("collapseExpandedComposerCursor", () => {
543546 });
544547});
545548
549it("preserves the caret before trailing text after mixed-width skill chips", () => {
550 const text = "𑿝ui a $review tail";
551 const expanded = "𑿝ui a $review ".length;
552 expect(collapseExpandedComposerCursor(text, expanded)).toBe(6);
553 expect(expandCollapsedComposerCursor(text, 6)).toBe(expanded);
554});
555
546556describe("clampCollapsedComposerCursor", () => {
547557 it("clamps to collapsed prompt length when mentions are present", () => {
548558 const text = "open @AGENTS.md then ";
apps/web/src/composer-logic.ts+5-4
......@@ -88,7 +88,7 @@ export function expandCollapsedComposerCursor(text: string, cursorInput: number)
8888 continue;
8989 }
9090 if (segment.type === "skill") {
91 const expandedLength = segment.name.length + 1;
91 const expandedLength = segment.source.length;
9292 if (remaining <= 1) {
9393 return expandedCursor + (remaining === 0 ? 0 : expandedLength);
9494 }
......@@ -164,7 +164,7 @@ export function collapseExpandedComposerCursor(text: string, cursorInput: number
164164 continue;
165165 }
166166 if (segment.type === "skill") {
167 const expandedLength = segment.name.length + 1;
167 const expandedLength = segment.source.length;
168168 if (remaining === 0) {
169169 return collapsedCursor;
170170 }
......@@ -244,10 +244,11 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos
244244 rangeEnd: cursor,
245245 };
246246 }
247 if (token.startsWith("$")) {
247 const skillPrefix = /^\p{Sc}/u.exec(token);
248 if (skillPrefix) {
248249 return {
249250 kind: "skill",
250 query: token.slice(1),
251 query: token.slice(skillPrefix[0].length),
251252 rangeStart: tokenStart,
252253 rangeEnd: cursor,
253254 };
apps/web/src/composer-rich-text-doc.test.ts+10
......@@ -118,6 +118,16 @@ function roundTripPlain(value: string) {
118118}
119119
120120describe("composer rich text document model", () => {
121 it.each(["€", "£", "¥", "₹", "₩", "₿", "𑿝"])(
122 "canonicalizes %s skill aliases while preserving amounts",
123 (prefix) => {
124 const value = `Use ${prefix}my-skill for ${prefix}20 please`;
125 const expected = `Use $my-skill for ${prefix}20 please`;
126 expect(roundTrip(value).value).toBe(expected);
127 expect(roundTripPlain(value).value).toBe(expected);
128 },
129 );
130
121131 it.each([
122132 "",
123133 "\n\n",
apps/web/src/providerSkillSearch.test.ts+8
......@@ -13,6 +13,14 @@ function makeSkill(input: Partial<ServerProviderSkill> & Pick<ServerProviderSkil
1313}
1414
1515describe("searchProviderSkills", () => {
16 it.each(["$", "€", "£", "¥", "₹", "₩", "₿", "𑿝"])(
17 "searches skills with a %s prefix",
18 (prefix) => {
19 const skill = makeSkill({ name: "review" });
20 expect(searchProviderSkills([skill], `${prefix}review`)).toEqual([skill]);
21 },
22 );
23
1624 it("moves exact ui matches ahead of broader ui matches", () => {
1725 const skills = [
1826 makeSkill({
apps/web/src/providerSkillSearch.ts+1-1
......@@ -75,7 +75,7 @@ export function searchProviderSkills(
7575 limit = Number.POSITIVE_INFINITY,
7676): ServerProviderSkill[] {
7777 const enabledSkills = dedupeProviderSkillsByName(skills.filter(isProviderSkillUserInvocable));
78 const normalizedQuery = normalizeSearchQuery(query, { trimLeadingPattern: /^\$+/ });
78 const normalizedQuery = normalizeSearchQuery(query, { trimLeadingPattern: /^\p{Sc}+/u });
7979
8080 if (!normalizedQuery) {
8181 return enabledSkills;
packages/shared/src/composerInlineTokens.test.ts+21-10
......@@ -31,16 +31,27 @@ describe("collectComposerInlineTokens", () => {
3131 ]);
3232 });
3333
34 it("collects skill names that begin with a digit", () => {
35 expect(collectComposerInlineTokens("Use $2spec next")).toEqual([
36 {
37 type: "skill",
38 value: "2spec",
39 source: "$2spec",
40 start: 4,
41 end: 10,
42 },
43 ]);
34 it.each(["$", "€", "£", "¥", "₹", "₩", "₿", "𑿝"])(
35 "collects %s skill names that begin with a digit",
36 (prefix) => {
37 expect(collectComposerInlineTokens(`Use ${prefix}2spec next`)).toEqual([
38 {
39 type: "skill",
40 value: "2spec",
41 source: `${prefix}2spec`,
42 start: 4,
43 end: 9 + prefix.length,
44 },
45 ]);
46 },
47 );
48
49 it.each(["€", "£", "¥", "₹", "₩", "₿", "𑿝"])("leaves %s currency amounts as text", (prefix) => {
50 expect(
51 collectComposerInlineTokens(
52 `${prefix}20 ${prefix}1_000 ${prefix}20k ${prefix}100M ${prefix}1e6 `,
53 ),
54 ).toEqual([]);
4455 });
4556
4657 it("leaves digits-only dollar amounts and compact monetary expressions as text", () => {
packages/shared/src/composerInlineTokens.ts+1-1
......@@ -26,7 +26,7 @@ export interface CollectComposerInlineTokensOptions {
2626 * contain at least one letter.
2727 */
2828const SKILL_TOKEN_REGEX =
29 /(^|\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;
29 /(^|\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;
3030const MENTION_TOKEN_REGEX = /(^|\s)@(?:"((?:\\.|[^"\\])*)"|([^\s@"]+))(?=\s)/g;
3131/**
3232 * The label body is bounded rather than `*`. Unbounded, every whitespace in
packages/shared/src/composerTrigger.test.ts+16-1
......@@ -1,6 +1,21 @@
11import { describe, expect, it } from "vite-plus/test";
22
3import { serializeComposerFileLink } from "./composerTrigger.ts";
3import { detectComposerTrigger, serializeComposerFileLink } from "./composerTrigger.ts";
4
5describe("detectComposerTrigger", () => {
6 it.each(["$", "€", "£", "¥", "₹", "₩", "₿", "𑿝"])(
7 "detects %s skill prefixes and their source range",
8 (prefix) => {
9 const text = `Use ${prefix}review`;
10 expect(detectComposerTrigger(text, text.length)).toEqual({
11 kind: "skill",
12 query: "review",
13 rangeStart: 4,
14 rangeEnd: text.length,
15 });
16 },
17 );
18});
419
520describe("serializeComposerFileLink", () => {
621 it("uses the basename as the markdown label", () => {
packages/shared/src/composerTrigger.ts+3-2
......@@ -108,10 +108,11 @@ export function detectComposerTrigger(
108108 rangeStart: tokenStart,
109109 rangeEnd: cursor,
110110 };
111 if (token.startsWith("$")) {
111 const skillPrefix = /^\p{Sc}/u.exec(token);
112 if (skillPrefix) {
112113 return {
113114 kind: "skill",
114 query: token.slice(1),
115 query: token.slice(skillPrefix[0].length),
115116 rangeStart: tokenStart,
116117 rangeEnd: cursor,
117118 };