| author | |
| committer | |
| log | 82f64cd8d046ab4ee8588f7bcbc1e66a4a0c80fe |
| tree | e44af8be389d0e1acf9c0305c4160ad5b6bd090f |
| parent | 896fe82f2f191eb8ac0fd620e19c25dd48253592 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com>11 files changed, 157 insertions(+), 7 deletions(-)
apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts+2-1| ... | ... | @@ -195,7 +195,8 @@ function appendRun( |
| 195 | 195 | return runs; |
| 196 | 196 | } |
| 197 | 197 | |
| 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; | |
| 199 | 200 | |
| 200 | 201 | function formatSkillLabel(skill: SelectableMarkdownSkill): string { |
| 201 | 202 | const displayName = skill.displayName?.trim(); |
apps/mobile/src/lib/nativeMarkdownText.test.ts+23| ... | ... | @@ -226,6 +226,29 @@ describe("nativeMarkdownDocumentRuns", () => { |
| 226 | 226 | ]); |
| 227 | 227 | }); |
| 228 | 228 | |
| 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 | ||
| 229 | 252 | it("decorates known skill references inside blockquotes", () => { |
| 230 | 253 | const node: MarkdownNode = { |
| 231 | 254 | type: "blockquote", |
apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts+15-1| ... | ... | @@ -2,7 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | |
| 3 | 3 | import { planClaudeSkillDispatch } from "./ClaudeSkillDispatch.ts"; |
| 4 | 4 | |
| 5 | const SKILLS = new Set(["implement", "review", "re-release-version"]); | |
| 5 | const SKILLS = new Set(["2spec", "implement", "review", "re-release-version"]); | |
| 6 | 6 | |
| 7 | 7 | describe("planClaudeSkillDispatch", () => { |
| 8 | 8 | it("leaves a prompt without a known skill untouched", () => { |
| ... | ... | @@ -27,6 +27,14 @@ describe("planClaudeSkillDispatch", () => { |
| 27 | 27 | }); |
| 28 | 28 | }); |
| 29 | 29 | |
| 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 | ||
| 30 | 38 | it("dispatches the last mention and rewrites earlier ones inline", () => { |
| 31 | 39 | expect(planClaudeSkillDispatch("$review the diff, then $implement the fixes", SKILLS)).toEqual({ |
| 32 | 40 | leadingText: "/review the diff, then", |
| ... | ... | @@ -38,4 +46,10 @@ describe("planClaudeSkillDispatch", () => { |
| 38 | 46 | it("ignores a dollar token glued to other text", () => { |
| 39 | 47 | expect(planClaudeSkillDispatch("cost is 5$implement", SKILLS)).toBeUndefined(); |
| 40 | 48 | }); |
| 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 | }); | |
| 41 | 55 | }); |
apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts+2-1| ... | ... | @@ -29,7 +29,8 @@ |
| 29 | 29 | * (`packages/shared/src/composerInlineTokens.ts`), so a rendered chip and a |
| 30 | 30 | * dispatched skill are always the same set. |
| 31 | 31 | */ |
| 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; | |
| 33 | 34 | |
| 34 | 35 | export interface ClaudeSkillDispatch { |
| 35 | 36 | /** 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"; |
| 19 | 19 | import { parse as parseYamlDocument } from "yaml"; |
| 20 | 20 | |
| 21 | 21 | 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); | |
| 24 | 25 | const MAX_SKILL_DEPTH = 10; |
| 25 | 26 | const MAX_SKILL_BYTES = FileSystem.Size(1_000_000); |
| 26 | 27 | const MAX_SKILL_SCAN_ENTRIES = 10_000; |
apps/server/src/provider/Layers/CursorProvider.test.ts+18| ... | ... | @@ -455,6 +455,24 @@ describe("Cursor skills", () => { |
| 455 | 455 | "please /review this", |
| 456 | 456 | ); |
| 457 | 457 | }); |
| 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 | }); | |
| 458 | 476 | }); |
| 459 | 477 | |
| 460 | 478 | describe("getCursorFallbackModels", () => { |
apps/web/src/components/ChatMarkdown.test.tsx+40| ... | ... | @@ -293,6 +293,46 @@ describe("hasMarkdownFilePrimaryAction", () => { |
| 293 | 293 | }); |
| 294 | 294 | }); |
| 295 | 295 | |
| 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 | ||
| 296 | 336 | describe("ChatMarkdown file option chips", () => { |
| 297 | 337 | it("keeps the fallback button text selectable", () => { |
| 298 | 338 | const html = renderToStaticMarkup( |
apps/web/src/components/chat/SkillInlineText.tsx+2-1| ... | ... | @@ -10,7 +10,8 @@ import { |
| 10 | 10 | } from "../composerInlineChip"; |
| 11 | 11 | import { cn } from "~/lib/utils"; |
| 12 | 12 | |
| 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; | |
| 14 | 15 | |
| 15 | 16 | type InlineSkill = Pick<ServerProviderSkill, "name" | "displayName">; |
| 16 | 17 |
apps/web/src/composer-editor-mentions.test.ts+23| ... | ... | @@ -198,6 +198,29 @@ describe("splitPromptIntoComposerSegments", () => { |
| 198 | 198 | ]); |
| 199 | 199 | }); |
| 200 | 200 | |
| 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 | ||
| 201 | 224 | it("does not convert an incomplete trailing skill token", () => { |
| 202 | 225 | expect(splitPromptIntoComposerSegments("Use $review-follow-up")).toEqual([ |
| 203 | 226 | { type: "text", text: "Use $review-follow-up" }, |
packages/shared/src/composerInlineTokens.test.ts+20| ... | ... | @@ -31,6 +31,26 @@ describe("collectComposerInlineTokens", () => { |
| 31 | 31 | ]); |
| 32 | 32 | }); |
| 33 | 33 | |
| 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 | ||
| 34 | 54 | it("does not convert incomplete trailing tokens", () => { |
| 35 | 55 | expect(collectComposerInlineTokens("Use $ui")).toEqual([]); |
| 36 | 56 | expect(collectComposerInlineTokens("Inspect @AGENTS.md")).toEqual([]); |
packages/shared/src/composerInlineTokens.ts+9-1| ... | ... | @@ -18,7 +18,15 @@ export interface CollectComposerInlineTokensOptions { |
| 18 | 18 | readonly preserveTrailingFrom?: ReadonlyArray<ComposerInlineToken>; |
| 19 | 19 | } |
| 20 | 20 | |
| 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; | |
| 22 | 30 | const MENTION_TOKEN_REGEX = /(^|\s)@(?:"((?:\\.|[^"\\])*)"|([^\s@"]+))(?=\s)/g; |
| 23 | 31 | /** |
| 24 | 32 | * The label body is bounded rather than `*`. Unbounded, every whitespace in |