From 82f64cd8d046ab4ee8588f7bcbc1e66a4a0c80fe Mon Sep 17 00:00:00 2001 From: Amirali Beigi <43336552+amiralibg@users.noreply.github.com> Date: Sat, 5 Sep 2026 04:43:43 +0330 Subject: [PATCH] fix(skills): support names beginning with digits (#9244) Co-authored-by: Claude Opus 5 Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> --- .../src/nativeMarkdownText.ts | 3 +- .../mobile/src/lib/nativeMarkdownText.test.ts | 23 +++++++++++ .../Drivers/ClaudeSkillDispatch.test.ts | 16 +++++++- .../provider/Drivers/ClaudeSkillDispatch.ts | 3 +- .../src/provider/Drivers/CursorSkills.ts | 5 ++- .../provider/Layers/CursorProvider.test.ts | 18 +++++++++ apps/web/src/components/ChatMarkdown.test.tsx | 40 +++++++++++++++++++ .../src/components/chat/SkillInlineText.tsx | 3 +- apps/web/src/composer-editor-mentions.test.ts | 23 +++++++++++ .../shared/src/composerInlineTokens.test.ts | 20 ++++++++++ packages/shared/src/composerInlineTokens.ts | 10 ++++- 11 files changed, 157 insertions(+), 7 deletions(-) diff --git a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts index 2b39ac201599af2d92f3265550d59ea65bb8919a..4c8a6c4d7cd5ac21e2e506033b142db39912366a 100644 --- a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts +++ b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts @@ -195,7 +195,8 @@ function appendRun( return runs; } -const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s|$)/g; +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; function formatSkillLabel(skill: SelectableMarkdownSkill): string { const displayName = skill.displayName?.trim(); diff --git a/apps/mobile/src/lib/nativeMarkdownText.test.ts b/apps/mobile/src/lib/nativeMarkdownText.test.ts index 1e7cb5f3164e204d6ec93279b2b9eabc31119643..c3951c8d81f05d517cfac77637e19dbbcf3fde75 100644 --- a/apps/mobile/src/lib/nativeMarkdownText.test.ts +++ b/apps/mobile/src/lib/nativeMarkdownText.test.ts @@ -226,6 +226,29 @@ describe("nativeMarkdownDocumentRuns", () => { ]); }); + it("decorates known skill references that begin with a digit", () => { + const node: MarkdownNode = { + type: "document", + children: [ + { + type: "paragraph", + children: [{ type: "text", content: "Use $2spec for this." }], + }, + ], + }; + + expect(nativeMarkdownDocumentRuns(node, [{ name: "2spec", displayName: "2Spec" }])).toEqual([ + { text: "Use ", role: "body" }, + { + text: "$2spec", + role: "body", + skillName: "2spec", + skillLabel: "2Spec", + }, + { text: " for this.", role: "body" }, + ]); + }); + it("decorates known skill references inside blockquotes", () => { const node: MarkdownNode = { type: "blockquote", diff --git a/apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts index 99074c8b07edeacefc9aa8b3468105e7703efd00..e6e6da9d5e9e152e3cebe08a1a3a0647bdcb0a57 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import { planClaudeSkillDispatch } from "./ClaudeSkillDispatch.ts"; -const SKILLS = new Set(["implement", "review", "re-release-version"]); +const SKILLS = new Set(["2spec", "implement", "review", "re-release-version"]); describe("planClaudeSkillDispatch", () => { it("leaves a prompt without a known skill untouched", () => { @@ -27,6 +27,14 @@ describe("planClaudeSkillDispatch", () => { }); }); + it("dispatches a known skill whose name begins with a digit", () => { + expect(planClaudeSkillDispatch("use $2spec for this", SKILLS)).toEqual({ + leadingText: "use", + commandText: "/2spec for this", + skillName: "2spec", + }); + }); + it("dispatches the last mention and rewrites earlier ones inline", () => { expect(planClaudeSkillDispatch("$review the diff, then $implement the fixes", SKILLS)).toEqual({ leadingText: "/review the diff, then", @@ -38,4 +46,10 @@ describe("planClaudeSkillDispatch", () => { 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(); + }); }); diff --git a/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts index a008e0f9ec9b04ffcf15119fd6889a66e0dd220a..27e7dcf68bdf7d2b49dd488d9a1fcdcdc5da1149 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts @@ -29,7 +29,8 @@ * (`packages/shared/src/composerInlineTokens.ts`), so a rendered chip and a * dispatched skill are always the same set. */ -const SKILL_MENTION_PATTERN = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s|$)/g; +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; export interface ClaudeSkillDispatch { /** Text before the dispatched mention, or `undefined` when it opens the prompt. */ diff --git a/apps/server/src/provider/Drivers/CursorSkills.ts b/apps/server/src/provider/Drivers/CursorSkills.ts index 7b0637267c80da9f77c7586b67d6951ee1e660fb..599dc712d46a9b4bbaddab2854ad9ece1e8d3b38 100644 --- a/apps/server/src/provider/Drivers/CursorSkills.ts +++ b/apps/server/src/provider/Drivers/CursorSkills.ts @@ -19,8 +19,9 @@ import * as Schema from "effect/Schema"; import { parse as parseYamlDocument } from "yaml"; const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/; -const SKILL_MENTION_PATTERN = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s|$)/g; -const HAS_SKILL_MENTION_PATTERN = /(^|\s)\$[a-zA-Z][a-zA-Z0-9:_-]*(?=\s|$)/; +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); 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/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index a01958dcccac9b870e4581c7603db627fb296c46..78edd8acbd45a4f030a3981d5cc606d734644896 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -455,6 +455,24 @@ describe("Cursor skills", () => { "please /review this", ); }); + + 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. + expect(hasCursorSkillMention("use $2spec here")).toBe(true); + expect(hasCursorSkillMention("use $2spec here")).toBe(true); + expect(rewriteCursorSkillMentions("use $2spec here", names)).toBe("use /2spec here"); + expect(rewriteCursorSkillMentions("use $2spec here", new Set())).toBe("use $2spec here"); + for (const text of [ + "pay $20 tomorrow", + "budget $20k here", + "cost $100M total", + "limit $1e6 here", + ]) { + expect(hasCursorSkillMention(text)).toBe(false); + expect(rewriteCursorSkillMentions(text, names)).toBe(text); + } + }); }); describe("getCursorFallbackModels", () => { diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index 28ac42888eefc20107af5ac0970088526db7a802..9762506531a849bfb102cab8611777e4e97a8293 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -293,6 +293,46 @@ describe("hasMarkdownFilePrimaryAction", () => { }); }); +describe("ChatMarkdown skill chips", () => { + it("updates digit-leading skill labels when discovered skills change", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + let renderer: ReactTestRenderer | undefined; + const text = "Use $2spec with a $20k budget."; + try { + await act(async () => { + renderer = create(); + }); + const mounted = renderer!; + const labels = (label: string) => + mounted.root.findAllByType("span").filter((node) => node.children.includes(label)); + expect(labels("2Spec")).toHaveLength(0); + + await act(async () => { + mounted.update( + , + ); + }); + expect(labels("2Spec")).toHaveLength(1); + expect(labels("MoneySkill")).toHaveLength(0); + + await act(async () => { + mounted.update(); + }); + expect(labels("2Spec")).toHaveLength(0); + } finally { + await act(async () => renderer?.unmount()); + vi.unstubAllGlobals(); + } + }); +}); + describe("ChatMarkdown file option chips", () => { it("keeps the fallback button text selectable", () => { const html = renderToStaticMarkup( diff --git a/apps/web/src/components/chat/SkillInlineText.tsx b/apps/web/src/components/chat/SkillInlineText.tsx index 6d026ea58ccec2709e34ffe736afe848a44b5d48..b6e3985391331bfa9a56cee471c81e7f98ef9181 100644 --- a/apps/web/src/components/chat/SkillInlineText.tsx +++ b/apps/web/src/components/chat/SkillInlineText.tsx @@ -10,7 +10,8 @@ import { } from "../composerInlineChip"; import { cn } from "~/lib/utils"; -const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s|$)/g; +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; type InlineSkill = Pick; diff --git a/apps/web/src/composer-editor-mentions.test.ts b/apps/web/src/composer-editor-mentions.test.ts index 33f37af6802b2d3bfb7f4aab55f306a0e661c850..337fe7b8ae64b70ea96fcf76da34726e62d607c5 100644 --- a/apps/web/src/composer-editor-mentions.test.ts +++ b/apps/web/src/composer-editor-mentions.test.ts @@ -198,6 +198,29 @@ 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: "text", text: " please" }, + ]); + }); + + it("keeps digits-only dollar amounts and compact monetary expressions as text", () => { + expect(splitPromptIntoComposerSegments("I'll pay $20 tomorrow")).toEqual([ + { type: "text", text: "I'll pay $20 tomorrow" }, + ]); + expect(splitPromptIntoComposerSegments("Budget is $20k tomorrow")).toEqual([ + { type: "text", text: "Budget is $20k tomorrow" }, + ]); + expect(splitPromptIntoComposerSegments("Cost is $100M total")).toEqual([ + { type: "text", text: "Cost is $100M total" }, + ]); + expect(splitPromptIntoComposerSegments("Limit is $1e6 here")).toEqual([ + { type: "text", text: "Limit is $1e6 here" }, + ]); + }); + it("does not convert an incomplete trailing skill token", () => { expect(splitPromptIntoComposerSegments("Use $review-follow-up")).toEqual([ { type: "text", text: "Use $review-follow-up" }, diff --git a/packages/shared/src/composerInlineTokens.test.ts b/packages/shared/src/composerInlineTokens.test.ts index 81fd6add20564011e7b24567ca2c4a1b88485c29..5ce8f6281184b7d0a61e340a79105f47fe32f0df 100644 --- a/packages/shared/src/composerInlineTokens.test.ts +++ b/packages/shared/src/composerInlineTokens.test.ts @@ -31,6 +31,26 @@ 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("leaves digits-only dollar amounts and compact monetary expressions as text", () => { + expect(collectComposerInlineTokens("I'll pay $20 tomorrow")).toEqual([]); + expect(collectComposerInlineTokens("Budget is $1_000 total")).toEqual([]); + expect(collectComposerInlineTokens("Budget is $20k tomorrow")).toEqual([]); + expect(collectComposerInlineTokens("Cost is $100M total")).toEqual([]); + expect(collectComposerInlineTokens("Limit is $1e6 here")).toEqual([]); + }); + it("does not convert incomplete trailing tokens", () => { expect(collectComposerInlineTokens("Use $ui")).toEqual([]); expect(collectComposerInlineTokens("Inspect @AGENTS.md")).toEqual([]); diff --git a/packages/shared/src/composerInlineTokens.ts b/packages/shared/src/composerInlineTokens.ts index 11a5accf37b71b5b23e3eb6751bd63c598c5fda5..bb39dd59947509025b35041d617fa61bedffe932 100644 --- a/packages/shared/src/composerInlineTokens.ts +++ b/packages/shared/src/composerInlineTokens.ts @@ -18,7 +18,15 @@ export interface CollectComposerInlineTokensOptions { readonly preserveTrailingFrom?: ReadonlyArray; } -const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s)/g; +/** + * A skill name may start with a digit, but compact monetary amounts and + * numeric expressions like "$20", "$20k", "$100M", and "$1e6" must stay prose: + * the composer chips any matched `$name` token, known or not. Tokens beginning + * with digits must not match numbers with currency/exponent suffixes, and must + * 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; const MENTION_TOKEN_REGEX = /(^|\s)@(?:"((?:\\.|[^"\\])*)"|([^\s@"]+))(?=\s)/g; /** * The label body is bounded rather than `*`. Unbounded, every whitespace in -- 2.54.0