History

fix(skills): support names beginning with digits (#9244)

Amirali Beigi committed 2 months ago

Commit 82f64cd1 parent 896fe8211 files · +157 −7Browse files

Unified · Split

11 files
  1. apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts +2 −1
  2. apps/mobile/src/lib/nativeMarkdownText.test.ts +23 −0
  3. apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts +15 −1
  4. apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts +2 −1
  5. apps/server/src/provider/Drivers/CursorSkills.ts +3 −2
  6. apps/server/src/provider/Layers/CursorProvider.test.ts +18 −0
  7. apps/web/src/components/ChatMarkdown.test.tsx +40 −0
  8. apps/web/src/components/chat/SkillInlineText.tsx +2 −1
  9. apps/web/src/composer-editor-mentions.test.ts +23 −0
  10. packages/shared/src/composerInlineTokens.test.ts +20 −0
  11. packages/shared/src/composerInlineTokens.ts +9 −1
apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts+2−1
@@ -195,7 +195,8 @@ function appendRun( · 194 unmodified lines
195195 return runs;
196196 }
197197
198−const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s|$)/g;
198+const SKILL_TOKEN_REGEX =
199+ /(^|\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;
199200
200201 function formatSkillLabel(skill: SelectableMarkdownSkill): string {
201202 const displayName = skill.displayName?.trim();
apps/mobile/src/lib/nativeMarkdownText.test.ts+23−0
@@ -226,6 +226,29 @@ describe("nativeMarkdownDocumentRuns", () => { · 225 unmodified lines
226226 ]);
227227 });
228228
229+ it("decorates known skill references that begin with a digit", () => {
230+ const node: MarkdownNode = {
231+ type: "document",
232+ children: [
233+ {
234+ type: "paragraph",
235+ children: [{ type: "text", content: "Use $2spec for this." }],
236+ },
237+ ],
238+ };
239+
240+ expect(nativeMarkdownDocumentRuns(node, [{ name: "2spec", displayName: "2Spec" }])).toEqual([
241+ { text: "Use ", role: "body" },
242+ {
243+ text: "$2spec",
244+ role: "body",
245+ skillName: "2spec",
246+ skillLabel: "2Spec",
247+ },
248+ { text: " for this.", role: "body" },
249+ ]);
250+ });
251+
229252 it("decorates known skill references inside blockquotes", () => {
230253 const node: MarkdownNode = {
231254 type: "blockquote",
apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts+15−1
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; · 1 unmodified line
22
33 import { planClaudeSkillDispatch } from "./ClaudeSkillDispatch.ts";
44
5−const SKILLS = new Set(["implement", "review", "re-release-version"]);
5+const SKILLS = new Set(["2spec", "implement", "review", "re-release-version"]);
66
77 describe("planClaudeSkillDispatch", () => {
88 it("leaves a prompt without a known skill untouched", () => {
@@ -27,6 +27,14 @@ describe("planClaudeSkillDispatch", () => { · 18 unmodified lines
2727 });
2828 });
2929
30+ it("dispatches a known skill whose name begins with a digit", () => {
31+ expect(planClaudeSkillDispatch("use $2spec for this", SKILLS)).toEqual({
32+ leadingText: "use",
33+ commandText: "/2spec for this",
34+ skillName: "2spec",
35+ });
36+ });
37+
3038 it("dispatches the last mention and rewrites earlier ones inline", () => {
3139 expect(planClaudeSkillDispatch("$review the diff, then $implement the fixes", SKILLS)).toEqual({
3240 leadingText: "/review the diff, then",
@@ -38,4 +46,10 @@ describe("planClaudeSkillDispatch", () => { · 5 unmodified lines
3846 it("ignores a dollar token glued to other text", () => {
3947 expect(planClaudeSkillDispatch("cost is 5$implement", SKILLS)).toBeUndefined();
4048 });
49+
50+ 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();
54+ });
4155 });
apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts+2−1
@@ -29,7 +29,8 @@ · 28 unmodified lines
2929 * (`packages/shared/src/composerInlineTokens.ts`), so a rendered chip and a
3030 * dispatched skill are always the same set.
3131 */
32−const SKILL_MENTION_PATTERN = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s|$)/g;
32+const 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;
3334
3435 export interface ClaudeSkillDispatch {
3536 /** Text before the dispatched mention, or `undefined` when it opens the prompt. */
apps/server/src/provider/Drivers/CursorSkills.ts+3−2
@@ -19,8 +19,9 @@ import * as Schema from "effect/Schema"; · 18 unmodified lines
1919 import { parse as parseYamlDocument } from "yaml";
2020
2121 const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
22−const SKILL_MENTION_PATTERN = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s|$)/g;
23−const HAS_SKILL_MENTION_PATTERN = /(^|\s)\$[a-zA-Z][a-zA-Z0-9:_-]*(?=\s|$)/;
22+const 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;
24+const HAS_SKILL_MENTION_PATTERN = new RegExp(SKILL_MENTION_PATTERN.source);
2425 const MAX_SKILL_DEPTH = 10;
2526 const MAX_SKILL_BYTES = FileSystem.Size(1_000_000);
2627 const MAX_SKILL_SCAN_ENTRIES = 10_000;
apps/server/src/provider/Layers/CursorProvider.test.ts+18−0
@@ -455,6 +455,24 @@ describe("Cursor skills", () => { · 454 unmodified lines
455455 "please /review this",
456456 );
457457 });
458+
459+ it("detects and invokes digit-leading Cursor skills without rewriting money", () => {
460+ const names = new Set(["2spec", "20k", "100M", "1e6"]);
461+ // Repeated presence checks must not carry a global-regex cursor.
462+ expect(hasCursorSkillMention("use $2spec here")).toBe(true);
463+ expect(hasCursorSkillMention("use $2spec here")).toBe(true);
464+ expect(rewriteCursorSkillMentions("use $2spec here", names)).toBe("use /2spec here");
465+ expect(rewriteCursorSkillMentions("use $2spec here", new Set())).toBe("use $2spec here");
466+ for (const text of [
467+ "pay $20 tomorrow",
468+ "budget $20k here",
469+ "cost $100M total",
470+ "limit $1e6 here",
471+ ]) {
472+ expect(hasCursorSkillMention(text)).toBe(false);
473+ expect(rewriteCursorSkillMentions(text, names)).toBe(text);
474+ }
475+ });
458476 });
459477
460478 describe("getCursorFallbackModels", () => {
apps/web/src/components/ChatMarkdown.test.tsx+40−0
@@ -293,6 +293,46 @@ describe("hasMarkdownFilePrimaryAction", () => { · 292 unmodified lines
293293 });
294294 });
295295
296+describe("ChatMarkdown skill chips", () => {
297+ it("updates digit-leading skill labels when discovered skills change", async () => {
298+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
299+ let renderer: ReactTestRenderer | undefined;
300+ const text = "Use $2spec with a $20k budget.";
301+ try {
302+ await act(async () => {
303+ renderer = create(<ChatMarkdown cwd="/tmp/project" text={text} />);
304+ });
305+ const mounted = renderer!;
306+ const labels = (label: string) =>
307+ mounted.root.findAllByType("span").filter((node) => node.children.includes(label));
308+ expect(labels("2Spec")).toHaveLength(0);
309+
310+ await act(async () => {
311+ mounted.update(
312+ <ChatMarkdown
313+ cwd="/tmp/project"
314+ text={text}
315+ skills={[
316+ { name: "2spec", displayName: "2Spec" },
317+ { name: "20k", displayName: "MoneySkill" },
318+ ]}
319+ />,
320+ );
321+ });
322+ expect(labels("2Spec")).toHaveLength(1);
323+ expect(labels("MoneySkill")).toHaveLength(0);
324+
325+ await act(async () => {
326+ mounted.update(<ChatMarkdown cwd="/tmp/project" text={text} skills={[]} />);
327+ });
328+ expect(labels("2Spec")).toHaveLength(0);
329+ } finally {
330+ await act(async () => renderer?.unmount());
331+ vi.unstubAllGlobals();
332+ }
333+ });
334+});
335+
296336 describe("ChatMarkdown file option chips", () => {
297337 it("keeps the fallback button text selectable", () => {
298338 const html = renderToStaticMarkup(
apps/web/src/components/chat/SkillInlineText.tsx+2−1
@@ -10,7 +10,8 @@ import { · 9 unmodified lines
1010 } from "../composerInlineChip";
1111 import { cn } from "~/lib/utils";
1212
13−const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s|$)/g;
13+const SKILL_TOKEN_REGEX =
14+ /(^|\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;
1415
1516 type InlineSkill = Pick<ServerProviderSkill, "name" | "displayName">;
1617
apps/web/src/composer-editor-mentions.test.ts+23−0
@@ -198,6 +198,29 @@ describe("splitPromptIntoComposerSegments", () => { · 197 unmodified lines
198198 ]);
199199 });
200200
201+ it("splits digit-leading skill tokens into skill segments", () => {
202+ expect(splitPromptIntoComposerSegments("Use $2spec please")).toEqual([
203+ { type: "text", text: "Use " },
204+ { type: "skill", name: "2spec" },
205+ { type: "text", text: " please" },
206+ ]);
207+ });
208+
209+ it("keeps digits-only dollar amounts and compact monetary expressions as text", () => {
210+ expect(splitPromptIntoComposerSegments("I'll pay $20 tomorrow")).toEqual([
211+ { type: "text", text: "I'll pay $20 tomorrow" },
212+ ]);
213+ expect(splitPromptIntoComposerSegments("Budget is $20k tomorrow")).toEqual([
214+ { type: "text", text: "Budget is $20k tomorrow" },
215+ ]);
216+ expect(splitPromptIntoComposerSegments("Cost is $100M total")).toEqual([
217+ { type: "text", text: "Cost is $100M total" },
218+ ]);
219+ expect(splitPromptIntoComposerSegments("Limit is $1e6 here")).toEqual([
220+ { type: "text", text: "Limit is $1e6 here" },
221+ ]);
222+ });
223+
201224 it("does not convert an incomplete trailing skill token", () => {
202225 expect(splitPromptIntoComposerSegments("Use $review-follow-up")).toEqual([
203226 { type: "text", text: "Use $review-follow-up" },
packages/shared/src/composerInlineTokens.test.ts+20−0
@@ -31,6 +31,26 @@ describe("collectComposerInlineTokens", () => { · 30 unmodified lines
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+ ]);
44+ });
45+
46+ it("leaves digits-only dollar amounts and compact monetary expressions as text", () => {
47+ expect(collectComposerInlineTokens("I'll pay $20 tomorrow")).toEqual([]);
48+ expect(collectComposerInlineTokens("Budget is $1_000 total")).toEqual([]);
49+ expect(collectComposerInlineTokens("Budget is $20k tomorrow")).toEqual([]);
50+ expect(collectComposerInlineTokens("Cost is $100M total")).toEqual([]);
51+ expect(collectComposerInlineTokens("Limit is $1e6 here")).toEqual([]);
52+ });
53+
3454 it("does not convert incomplete trailing tokens", () => {
3555 expect(collectComposerInlineTokens("Use $ui")).toEqual([]);
3656 expect(collectComposerInlineTokens("Inspect @AGENTS.md")).toEqual([]);
packages/shared/src/composerInlineTokens.ts+9−1
@@ -18,7 +18,15 @@ export interface CollectComposerInlineTokensOptions { · 17 unmodified lines
1818 readonly preserveTrailingFrom?: ReadonlyArray<ComposerInlineToken>;
1919 }
2020
21−const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s)/g;
21+/**
22+ * A skill name may start with a digit, but compact monetary amounts and
23+ * numeric expressions like "$20", "$20k", "$100M", and "$1e6" must stay prose:
24+ * the composer chips any matched `$name` token, known or not. Tokens beginning
25+ * with digits must not match numbers with currency/exponent suffixes, and must
26+ * contain at least one letter.
27+ */
28+const 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;
2230 const MENTION_TOKEN_REGEX = /(^|\s)@(?:"((?:\\.|[^"\\])*)"|([^\s@"]+))(?=\s)/g;
2331 /**
2432 * The label body is bounded rather than `*`. Unbounded, every whitespace in