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(...@@ -319,7 +319,7 @@ function appendRun(
319}319}
320320
321const SKILL_TOKEN_REGEX =321const 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
324function formatSkillLabel(skill: SelectableMarkdownSkill): string {324function formatSkillLabel(skill: SelectableMarkdownSkill): string {
325 const displayName = skill.displayName?.trim();325 const displayName = skill.displayName?.trim();
...@@ -359,7 +359,7 @@ function decorateSkillRuns(...@@ -359,7 +359,7 @@ function decorateSkillRuns(
359 continue;359 continue;
360 }360 }
361 const start = (match.index ?? 0) + prefix.length;361 const start = (match.index ?? 0) + prefix.length;
362 const end = start + name.length + 1;362 const end = (match.index ?? 0) + match[0].length;
363 if (start > cursor) {363 if (start > cursor) {
364 decorated.push({ ...run, text: run.text.slice(cursor, start) });364 decorated.push({ ...run, text: run.text.slice(cursor, start) });
365 }365 }
apps/mobile/src/components/ComposerEditor.tsx+3-2
...@@ -155,8 +155,9 @@ export function ComposerEditor({...@@ -155,8 +155,9 @@ export function ComposerEditor({
155 const selectedReference = selected155 const selectedReference = selected
156 ? collectComposerContextReferences(selected.source)[0]156 ? collectComposerContextReferences(selected.source)[0]
157 : undefined;157 : undefined;
158 const selectedSkill = selected?.source.startsWith("$")158 const selectedSkillName = selected?.source.match(/^\p{Sc}(.+)$/u)?.[1];
159 ? props.skills?.find((skill) => skill.name === selected.source.slice(1))159 const selectedSkill = selectedSkillName
160 ? props.skills?.find((skill) => skill.name === selectedSkillName)
160 : undefined;161 : undefined;
161 const record = draft.context?.records.find(162 const record = draft.context?.records.find(
162 (entry) => entry.contextId === selectedReference?.contextId,163 (entry) => entry.contextId === selectedReference?.contextId,
apps/mobile/src/features/threads/use-composer-command-menu.ts+1-1
...@@ -366,7 +366,7 @@ export function useComposerCommandMenu({...@@ -366,7 +366,7 @@ export function useComposerCommandMenu({
366 if (trigger.kind === "skill") {366 if (trigger.kind === "skill") {
367 const enabledSkills = dedupeProviderSkillsByName(skills.filter(isProviderSkillUserInvocable));367 const enabledSkills = dedupeProviderSkillsByName(skills.filter(isProviderSkillUserInvocable));
368 const normalizedQuery = normalizeSearchQuery(trigger.query, {368 const normalizedQuery = normalizeSearchQuery(trigger.query, {
369 trimLeadingPattern: /^\$+/,369 trimLeadingPattern: /^\p{Sc}+/u,
370 });370 });
371371
372 if (!normalizedQuery) {372 if (!normalizedQuery) {
apps/mobile/src/lib/nativeMarkdownText.test.ts+23-20
...@@ -307,28 +307,31 @@ describe("nativeMarkdownDocumentRuns", () => {...@@ -307,28 +307,31 @@ describe("nativeMarkdownDocumentRuns", () => {
307 ]);307 ]);
308 });308 });
309309
310 it("decorates known skill references as selectable skill links", () => {310 it.each(["$", "€", "£", "¥", "₹", "₩", "₿", "𑿝"])(
311 const node: MarkdownNode = {311 "decorates %s skill references as selectable skill links",
312 type: "document",312 (prefix) => {
313 children: [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" },
314 {325 {
315 type: "paragraph",326 text: `${prefix}ui`,
316 children: [{ type: "text", content: "Use $ui for this." }],327 role: "body",
328 skillName: "ui",
329 skillLabel: "UI",
317 },330 },
318 ],331 { text: " for this.", role: "body" },
319 };332 ]);
320333 },
321 expect(nativeMarkdownDocumentRuns(node, [{ name: "ui", displayName: "UI" }])).toEqual([334 );
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 });
332335
333 it("decorates known skill references that begin with a digit", () => {336 it("decorates known skill references that begin with a digit", () => {
334 const node: MarkdownNode = {337 const node: MarkdownNode = {
apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts+30-3
...@@ -43,13 +43,40 @@ describe("planClaudeSkillDispatch", () => {...@@ -43,13 +43,40 @@ describe("planClaudeSkillDispatch", () => {
43 });43 });
44 });44 });
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
46 it("ignores a dollar token glued to other text", () => {67 it("ignores a dollar token glued to other text", () => {
47 expect(planClaudeSkillDispatch("cost is 5$implement", SKILLS)).toBeUndefined();68 expect(planClaudeSkillDispatch("cost is 5$implement", SKILLS)).toBeUndefined();
48 });69 });
4970
50 it("ignores currency amounts and compact monetary expressions", () => {71 it("ignores currency amounts and compact monetary expressions", () => {
51 const skillsWithCurrency = new Set([...SKILLS, "20", "20k", "100M"]);72 const skillsWithCurrency = new Set([...SKILLS, "20", "20k", "100M", "1e6"]);
52 expect(planClaudeSkillDispatch("pay $20 tomorrow", skillsWithCurrency)).toBeUndefined();73 for (const symbol of ["$", "€", "£", "¥", "₹", "₩", "₿", "𑿝"]) {
53 expect(planClaudeSkillDispatch("budget is $20k tomorrow", skillsWithCurrency)).toBeUndefined();74 expect(
75 planClaudeSkillDispatch(
76 `pay ${symbol}20 ${symbol}20k ${symbol}100M ${symbol}1e6 tomorrow`,
77 skillsWithCurrency,
78 ),
79 ).toBeUndefined();
80 }
54 });81 });
55});82});
apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts+4-3
...@@ -30,7 +30,7 @@...@@ -30,7 +30,7 @@
30 * dispatched skill are always the same set.30 * dispatched skill are always the same set.
31 */31 */
32const SKILL_MENTION_PATTERN =32const 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
35export interface ClaudeSkillDispatch {35export interface ClaudeSkillDispatch {
36 /** Text before the dispatched mention, or `undefined` when it opens the prompt. */36 /** Text before the dispatched mention, or `undefined` when it opens the prompt. */
...@@ -54,7 +54,7 @@ export function planClaudeSkillDispatch(...@@ -54,7 +54,7 @@ export function planClaudeSkillDispatch(
54 const name = match[2] ?? "";54 const name = match[2] ?? "";
55 if (!skillNames.has(name)) return [];55 if (!skillNames.has(name)) return [];
56 const start = (match.index ?? 0) + (match[1]?.length ?? 0);56 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 }];
58 });58 });
59 const last = mentions.at(-1);59 const last = mentions.at(-1);
60 if (!last) {60 if (!last) {
...@@ -66,7 +66,8 @@ export function planClaudeSkillDispatch(...@@ -66,7 +66,8 @@ export function planClaudeSkillDispatch(
66 const leadingWithInlineSlashes = mentions66 const leadingWithInlineSlashes = mentions
67 .slice(0, -1)67 .slice(0, -1)
68 .reduceRight(68 .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)}`,
70 leading,71 leading,
71 )72 )
72 .trimEnd();73 .trimEnd();
apps/server/src/provider/Drivers/CursorSkills.ts+2-2
...@@ -20,8 +20,8 @@ import { parse as parseYamlDocument } from "yaml";...@@ -20,8 +20,8 @@ import { parse as parseYamlDocument } from "yaml";
2020
21const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;21const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
22const SKILL_MENTION_PATTERN =22const 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;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);24const HAS_SKILL_MENTION_PATTERN = new RegExp(SKILL_MENTION_PATTERN.source, "u");
25const MAX_SKILL_DEPTH = 10;25const MAX_SKILL_DEPTH = 10;
26const MAX_SKILL_BYTES = FileSystem.Size(1_000_000);26const MAX_SKILL_BYTES = FileSystem.Size(1_000_000);
27const MAX_SKILL_SCAN_ENTRIES = 10_000;27const MAX_SKILL_SCAN_ENTRIES = 10_000;
apps/server/src/provider/Layers/CodexSessionRuntime.test.ts+17
...@@ -154,6 +154,23 @@ function makeThreadOpenResponse(...@@ -154,6 +154,23 @@ function makeThreadOpenResponse(
154}154}
155155
156describe("buildTurnStartParams", () => {156describe("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
157 it("keeps invalid turn values only in the schema cause", () => {174 it("keeps invalid turn values only in the schema cause", () => {
158 const secret = "codex-turn-input-secret-sentinel";175 const secret = "codex-turn-input-secret-sentinel";
159 const error = Effect.runSync(176 const error = Effect.runSync(
apps/server/src/provider/Layers/CodexSessionRuntime.ts+5-1
...@@ -605,6 +605,10 @@ function buildCodexCollaborationMode(input: {...@@ -605,6 +605,10 @@ function buildCodexCollaborationMode(input: {
605 };605 };
606}606}
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
608export function buildTurnStartParams(input: {612export function buildTurnStartParams(input: {
609 readonly threadId: string;613 readonly threadId: string;
610 readonly runtimeMode: RuntimeMode;614 readonly runtimeMode: RuntimeMode;
...@@ -627,7 +631,7 @@ export function buildTurnStartParams(input: {...@@ -627,7 +631,7 @@ export function buildTurnStartParams(input: {
627 if (input.prompt) {631 if (input.prompt) {
628 turnInput.push({632 turnInput.push({
629 type: "text",633 type: "text",
630 text: input.prompt,634 text: input.prompt.replace(SKILL_MENTION_PATTERN, "$1$$$2"),
631 });635 });
632 }636 }
633 for (const attachment of input.attachments ?? []) {637 for (const attachment of input.attachments ?? []) {
apps/server/src/provider/Layers/CursorProvider.test.ts+16
...@@ -461,6 +461,22 @@ describe("Cursor skills", () => {...@@ -461,6 +461,22 @@ describe("Cursor skills", () => {
461 );461 );
462 });462 });
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
464 it("detects and invokes digit-leading Cursor skills without rewriting money", () => {480 it("detects and invokes digit-leading Cursor skills without rewriting money", () => {
465 const names = new Set(["2spec", "20k", "100M", "1e6"]);481 const names = new Set(["2spec", "20k", "100M", "1e6"]);
466 // Repeated presence checks must not carry a global-regex cursor.482 // Repeated presence checks must not carry a global-regex cursor.
apps/web/src/components/chat/SkillInlineText.tsx+2-2
...@@ -12,7 +12,7 @@ import {...@@ -12,7 +12,7 @@ import {
12import { cn } from "~/lib/utils";12import { cn } from "~/lib/utils";
1313
14const SKILL_TOKEN_REGEX =14const 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
17type InlineSkill = Pick<ServerProviderSkill, "name" | "displayName">;17type InlineSkill = Pick<ServerProviderSkill, "name" | "displayName">;
1818
...@@ -34,7 +34,7 @@ export function SkillInlineText(props: { text: string; skills: ReadonlyArray<Inl...@@ -34,7 +34,7 @@ export function SkillInlineText(props: { text: string; skills: ReadonlyArray<Inl
34 nodes.push(props.text.slice(cursor, start));34 nodes.push(props.text.slice(cursor, start));
35 }35 }
36 nodes.push(<SkillChip key={`${start}:${name}`} skill={skill} rawText={rawText} />);36 nodes.push(<SkillChip key={`${start}:${name}`} skill={skill} rawText={rawText} />);
37 cursor = start + rawText.length;37 cursor = (match.index ?? 0) + match[0].length;
38 }38 }
3939
40 if (cursor === 0) {40 if (cursor === 0) {
apps/web/src/composer-editor-mentions.test.ts+4-4
...@@ -154,7 +154,7 @@ describe("splitPromptIntoComposerSegments", () => {...@@ -154,7 +154,7 @@ describe("splitPromptIntoComposerSegments", () => {
154 { type: "text", text: " " },154 { type: "text", text: " " },
155 { type: "citation", citation, source },155 { type: "citation", citation, source },
156 { type: "text", text: "\n" },156 { type: "text", text: "\n" },
157 { type: "skill", name: "review" },157 { type: "skill", name: "review", source: "$review" },
158 { type: "text", text: " " },158 { type: "text", text: " " },
159 {159 {
160 type: "context-reference",160 type: "context-reference",
...@@ -217,7 +217,7 @@ describe("splitPromptIntoComposerSegments", () => {...@@ -217,7 +217,7 @@ describe("splitPromptIntoComposerSegments", () => {
217 it("splits skill tokens followed by whitespace into skill segments", () => {217 it("splits skill tokens followed by whitespace into skill segments", () => {
218 expect(splitPromptIntoComposerSegments("Use $review-follow-up please")).toEqual([218 expect(splitPromptIntoComposerSegments("Use $review-follow-up please")).toEqual([
219 { type: "text", text: "Use " },219 { type: "text", text: "Use " },
220 { type: "skill", name: "review-follow-up" },220 { type: "skill", name: "review-follow-up", source: "$review-follow-up" },
221 { type: "text", text: " please" },221 { type: "text", text: " please" },
222 ]);222 ]);
223 });223 });
...@@ -225,7 +225,7 @@ describe("splitPromptIntoComposerSegments", () => {...@@ -225,7 +225,7 @@ describe("splitPromptIntoComposerSegments", () => {
225 it("splits digit-leading skill tokens into skill segments", () => {225 it("splits digit-leading skill tokens into skill segments", () => {
226 expect(splitPromptIntoComposerSegments("Use $2spec please")).toEqual([226 expect(splitPromptIntoComposerSegments("Use $2spec please")).toEqual([
227 { type: "text", text: "Use " },227 { type: "text", text: "Use " },
228 { type: "skill", name: "2spec" },228 { type: "skill", name: "2spec", source: "$2spec" },
229 { type: "text", text: " please" },229 { type: "text", text: " please" },
230 ]);230 ]);
231 });231 });
...@@ -278,7 +278,7 @@ describe("splitPromptIntoComposerSegments", () => {...@@ -278,7 +278,7 @@ describe("splitPromptIntoComposerSegments", () => {
278 { type: "text", text: "Inspect " },278 { type: "text", text: "Inspect " },
279 terminalSegment,279 terminalSegment,
280 { type: "text", text: " " },280 { type: "text", text: " " },
281 { type: "skill", name: "review-follow-up" },281 { type: "skill", name: "review-follow-up", source: "$review-follow-up" },
282 { type: "text", text: " after " },282 { type: "text", text: " after " },
283 { type: "mention", path: "AGENTS.md", source: "@AGENTS.md" },283 { type: "mention", path: "AGENTS.md", source: "@AGENTS.md" },
284 { type: "text", text: " " },284 { type: "text", text: " " },
apps/web/src/composer-editor-mentions.ts+2-1
...@@ -19,6 +19,7 @@ export type ComposerPromptSegment =...@@ -19,6 +19,7 @@ export type ComposerPromptSegment =
19 | {19 | {
20 type: "skill";20 type: "skill";
21 name: string;21 name: string;
22 source: string;
22 }23 }
23 | {24 | {
24 type: "citation";25 type: "citation";
...@@ -127,7 +128,7 @@ function splitPromptTextIntoComposerSegments(text: string): ComposerPromptSegmen...@@ -127,7 +128,7 @@ function splitPromptTextIntoComposerSegments(text: string): ComposerPromptSegmen
127 source: match.source,128 source: match.source,
128 });129 });
129 } else {130 } else {
130 segments.push({ type: "skill", name: match.value });131 segments.push({ type: "skill", name: match.value, source: match.source });
131 }132 }
132133
133 cursor = match.end;134 cursor = match.end;
apps/web/src/composer-list-continuation.test.ts+6
...@@ -48,6 +48,12 @@ describe("composer list continuation", () => {...@@ -48,6 +48,12 @@ describe("composer list continuation", () => {
48 expect(listContinuationForEnter("1. foo", 1)).toBeNull();48 expect(listContinuationForEnter("1. foo", 1)).toBeNull();
49 });49 });
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
51 it("refuses to split an inline chip", () => {57 it("refuses to split an inline chip", () => {
52 const value = "1. @README.md go";58 const value = "1. @README.md go";
53 const cursor = value.indexOf("README") + 2;59 const cursor = value.indexOf("README") + 2;
apps/web/src/composer-list-continuation.ts-1
...@@ -73,7 +73,6 @@ function segmentSource(...@@ -73,7 +73,6 @@ function segmentSource(
73 segment: ReturnType<typeof splitPromptIntoComposerSegments>[number],73 segment: ReturnType<typeof splitPromptIntoComposerSegments>[number],
74): string {74): string {
75 if (segment.type === "text") return segment.text;75 if (segment.type === "text") return segment.text;
76 if (segment.type === "skill") return `$${segment.name}`;
77 return segment.source;76 return segment.source;
78}77}
7978
apps/web/src/composer-logic.test.ts+27-17
...@@ -230,17 +230,20 @@ describe("detectComposerTrigger", () => {...@@ -230,17 +230,20 @@ describe("detectComposerTrigger", () => {
230 });230 });
231 });231 });
232232
233 it("detects $skill trigger at cursor", () => {233 it.each(["$", "€", "£", "¥", "₹", "₩", "₿", "𑿝"])(
234 const text = "Use $gh-fi";234 "detects %sskill trigger at cursor",
235 const trigger = detectComposerTrigger(text, text.length);235 (prefix) => {
236236 const text = `Use ${prefix}gh-fi`;
237 expect(trigger).toEqual({237 const trigger = detectComposerTrigger(text, text.length);
238 kind: "skill",238
239 query: "gh-fi",239 expect(trigger).toEqual({
240 rangeStart: "Use ".length,240 kind: "skill",
241 rangeEnd: text.length,241 query: "gh-fi",
242 });242 rangeStart: "Use ".length,
243 });243 rangeEnd: text.length,
244 });
245 },
246 );
244247
245 it("detects a pull request number at a token boundary", () => {248 it("detects a pull request number at a token boundary", () => {
246 const text = "Compare this with #8737";249 const text = "Compare this with #8737";
...@@ -468,10 +471,10 @@ describe("expandCollapsedComposerCursor", () => {...@@ -468,10 +471,10 @@ describe("expandCollapsedComposerCursor", () => {
468 expect(detectComposerTrigger(text, expandedCursor)).toBeNull();471 expect(detectComposerTrigger(text, expandedCursor)).toBeNull();
469 });472 });
470473
471 it("maps collapsed skill cursor to expanded text cursor", () => {474 it.each(["$", "€", "𑿝"])("maps collapsed %s skill cursor to expanded text cursor", (prefix) => {
472 const text = "run $review-follow-up then";475 const text = `run ${prefix}review-follow-up then`;
473 const collapsedCursorAfterSkill = "run ".length + 2;476 const collapsedCursorAfterSkill = "run ".length + 2;
474 const expandedCursorAfterSkill = "run $review-follow-up ".length;477 const expandedCursorAfterSkill = `run ${prefix}review-follow-up `.length;
475478
476 expect(expandCollapsedComposerCursor(text, collapsedCursorAfterSkill)).toBe(479 expect(expandCollapsedComposerCursor(text, collapsedCursorAfterSkill)).toBe(
477 expandedCursorAfterSkill,480 expandedCursorAfterSkill,
...@@ -532,10 +535,10 @@ describe("collapseExpandedComposerCursor", () => {...@@ -532,10 +535,10 @@ describe("collapseExpandedComposerCursor", () => {
532 expect(expandCollapsedComposerCursor(text, collapsedCursor)).toBe(expandedCursor);535 expect(expandCollapsedComposerCursor(text, collapsedCursor)).toBe(expandedCursor);
533 });536 });
534537
535 it("maps expanded skill cursor back to collapsed cursor", () => {538 it.each(["$", "€", "𑿝"])("maps expanded %s skill cursor back to collapsed cursor", (prefix) => {
536 const text = "run $review-follow-up then";539 const text = `run ${prefix}review-follow-up then`;
537 const collapsedCursorAfterSkill = "run ".length + 2;540 const collapsedCursorAfterSkill = "run ".length + 2;
538 const expandedCursorAfterSkill = "run $review-follow-up ".length;541 const expandedCursorAfterSkill = `run ${prefix}review-follow-up `.length;
539542
540 expect(collapseExpandedComposerCursor(text, expandedCursorAfterSkill)).toBe(543 expect(collapseExpandedComposerCursor(text, expandedCursorAfterSkill)).toBe(
541 collapsedCursorAfterSkill,544 collapsedCursorAfterSkill,
...@@ -543,6 +546,13 @@ describe("collapseExpandedComposerCursor", () => {...@@ -543,6 +546,13 @@ describe("collapseExpandedComposerCursor", () => {
543 });546 });
544});547});
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
546describe("clampCollapsedComposerCursor", () => {556describe("clampCollapsedComposerCursor", () => {
547 it("clamps to collapsed prompt length when mentions are present", () => {557 it("clamps to collapsed prompt length when mentions are present", () => {
548 const text = "open @AGENTS.md then ";558 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)...@@ -88,7 +88,7 @@ export function expandCollapsedComposerCursor(text: string, cursorInput: number)
88 continue;88 continue;
89 }89 }
90 if (segment.type === "skill") {90 if (segment.type === "skill") {
91 const expandedLength = segment.name.length + 1;91 const expandedLength = segment.source.length;
92 if (remaining <= 1) {92 if (remaining <= 1) {
93 return expandedCursor + (remaining === 0 ? 0 : expandedLength);93 return expandedCursor + (remaining === 0 ? 0 : expandedLength);
94 }94 }
...@@ -164,7 +164,7 @@ export function collapseExpandedComposerCursor(text: string, cursorInput: number...@@ -164,7 +164,7 @@ export function collapseExpandedComposerCursor(text: string, cursorInput: number
164 continue;164 continue;
165 }165 }
166 if (segment.type === "skill") {166 if (segment.type === "skill") {
167 const expandedLength = segment.name.length + 1;167 const expandedLength = segment.source.length;
168 if (remaining === 0) {168 if (remaining === 0) {
169 return collapsedCursor;169 return collapsedCursor;
170 }170 }
...@@ -244,10 +244,11 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos...@@ -244,10 +244,11 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos
244 rangeEnd: cursor,244 rangeEnd: cursor,
245 };245 };
246 }246 }
247 if (token.startsWith("$")) {247 const skillPrefix = /^\p{Sc}/u.exec(token);
248 if (skillPrefix) {
248 return {249 return {
249 kind: "skill",250 kind: "skill",
250 query: token.slice(1),251 query: token.slice(skillPrefix[0].length),
251 rangeStart: tokenStart,252 rangeStart: tokenStart,
252 rangeEnd: cursor,253 rangeEnd: cursor,
253 };254 };
apps/web/src/composer-rich-text-doc.test.ts+10
...@@ -118,6 +118,16 @@ function roundTripPlain(value: string) {...@@ -118,6 +118,16 @@ function roundTripPlain(value: string) {
118}118}
119119
120describe("composer rich text document model", () => {120describe("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
121 it.each([131 it.each([
122 "",132 "",
123 "\n\n",133 "\n\n",
apps/web/src/providerSkillSearch.test.ts+8
...@@ -13,6 +13,14 @@ function makeSkill(input: Partial<ServerProviderSkill> & Pick<ServerProviderSkil...@@ -13,6 +13,14 @@ function makeSkill(input: Partial<ServerProviderSkill> & Pick<ServerProviderSkil
13}13}
1414
15describe("searchProviderSkills", () => {15describe("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
16 it("moves exact ui matches ahead of broader ui matches", () => {24 it("moves exact ui matches ahead of broader ui matches", () => {
17 const skills = [25 const skills = [
18 makeSkill({26 makeSkill({
apps/web/src/providerSkillSearch.ts+1-1
...@@ -75,7 +75,7 @@ export function searchProviderSkills(...@@ -75,7 +75,7 @@ export function searchProviderSkills(
75 limit = Number.POSITIVE_INFINITY,75 limit = Number.POSITIVE_INFINITY,
76): ServerProviderSkill[] {76): ServerProviderSkill[] {
77 const enabledSkills = dedupeProviderSkillsByName(skills.filter(isProviderSkillUserInvocable));77 const enabledSkills = dedupeProviderSkillsByName(skills.filter(isProviderSkillUserInvocable));
78 const normalizedQuery = normalizeSearchQuery(query, { trimLeadingPattern: /^\$+/ });78 const normalizedQuery = normalizeSearchQuery(query, { trimLeadingPattern: /^\p{Sc}+/u });
7979
80 if (!normalizedQuery) {80 if (!normalizedQuery) {
81 return enabledSkills;81 return enabledSkills;
packages/shared/src/composerInlineTokens.test.ts+21-10
...@@ -31,16 +31,27 @@ describe("collectComposerInlineTokens", () => {...@@ -31,16 +31,27 @@ describe("collectComposerInlineTokens", () => {
31 ]);31 ]);
32 });32 });
3333
34 it("collects skill names that begin with a digit", () => {34 it.each(["$", "€", "£", "¥", "₹", "₩", "₿", "𑿝"])(
35 expect(collectComposerInlineTokens("Use $2spec next")).toEqual([35 "collects %s skill names that begin with a digit",
36 {36 (prefix) => {
37 type: "skill",37 expect(collectComposerInlineTokens(`Use ${prefix}2spec next`)).toEqual([
38 value: "2spec",38 {
39 source: "$2spec",39 type: "skill",
40 start: 4,40 value: "2spec",
41 end: 10,41 source: `${prefix}2spec`,
42 },42 start: 4,
43 ]);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([]);
44 });55 });
4556
46 it("leaves digits-only dollar amounts and compact monetary expressions as text", () => {57 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 {...@@ -26,7 +26,7 @@ export interface CollectComposerInlineTokensOptions {
26 * contain at least one letter.26 * contain at least one letter.
27 */27 */
28const SKILL_TOKEN_REGEX =28const 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;
30const MENTION_TOKEN_REGEX = /(^|\s)@(?:"((?:\\.|[^"\\])*)"|([^\s@"]+))(?=\s)/g;30const MENTION_TOKEN_REGEX = /(^|\s)@(?:"((?:\\.|[^"\\])*)"|([^\s@"]+))(?=\s)/g;
31/**31/**
32 * The label body is bounded rather than `*`. Unbounded, every whitespace in32 * The label body is bounded rather than `*`. Unbounded, every whitespace in
packages/shared/src/composerTrigger.test.ts+16-1
...@@ -1,6 +1,21 @@...@@ -1,6 +1,21 @@
1import { describe, expect, it } from "vite-plus/test";1import { 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
5describe("serializeComposerFileLink", () => {20describe("serializeComposerFileLink", () => {
6 it("uses the basename as the markdown label", () => {21 it("uses the basename as the markdown label", () => {
packages/shared/src/composerTrigger.ts+3-2
...@@ -108,10 +108,11 @@ export function detectComposerTrigger(...@@ -108,10 +108,11 @@ export function detectComposerTrigger(
108 rangeStart: tokenStart,108 rangeStart: tokenStart,
109 rangeEnd: cursor,109 rangeEnd: cursor,
110 };110 };
111 if (token.startsWith("$")) {111 const skillPrefix = /^\p{Sc}/u.exec(token);
112 if (skillPrefix) {
112 return {113 return {
113 kind: "skill",114 kind: "skill",
114 query: token.slice(1),115 query: token.slice(skillPrefix[0].length),
115 rangeStart: tokenStart,116 rangeStart: tokenStart,
116 rangeEnd: cursor,117 rangeEnd: cursor,
117 };118 };