| author | |
| committer | |
| log | 2a47fc905a7a1054823919980f625b7cd869f1c1 |
| tree | 523915eff1592ee49b21ecb01f47438afa699411 |
| parent | 01eb4b9a8776e6057dfacf12e7366565e5eab934 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 319 | } |
| 320 | 320 | |
| 321 | 321 | const 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; | |
| 323 | 323 | |
| 324 | 324 | function formatSkillLabel(skill: SelectableMarkdownSkill): string { |
| 325 | 325 | const displayName = skill.displayName?.trim(); |
| ... | ... | @@ -359,7 +359,7 @@ function decorateSkillRuns( |
| 359 | 359 | continue; |
| 360 | 360 | } |
| 361 | 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 | 363 | if (start > cursor) { |
| 364 | 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 | 155 | const selectedReference = selected |
| 156 | 156 | ? collectComposerContextReferences(selected.source)[0] |
| 157 | 157 | : 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) | |
| 160 | 161 | : undefined; |
| 161 | 162 | const record = draft.context?.records.find( |
| 162 | 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 | 366 | if (trigger.kind === "skill") { |
| 367 | 367 | const enabledSkills = dedupeProviderSkillsByName(skills.filter(isProviderSkillUserInvocable)); |
| 368 | 368 | const normalizedQuery = normalizeSearchQuery(trigger.query, { |
| 369 | trimLeadingPattern: /^\$+/, | |
| 369 | trimLeadingPattern: /^\p{Sc}+/u, | |
| 370 | 370 | }); |
| 371 | 371 | |
| 372 | 372 | if (!normalizedQuery) { |
apps/mobile/src/lib/nativeMarkdownText.test.ts+23-20| ... | ... | @@ -307,28 +307,31 @@ describe("nativeMarkdownDocumentRuns", () => { |
| 307 | 307 | ]); |
| 308 | 308 | }); |
| 309 | 309 | |
| 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" }, | |
| 314 | 325 | { |
| 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", | |
| 317 | 330 | }, |
| 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 | ); | |
| 332 | 335 | |
| 333 | 336 | it("decorates known skill references that begin with a digit", () => { |
| 334 | 337 | const node: MarkdownNode = { |
apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts+30-3| ... | ... | @@ -43,13 +43,40 @@ describe("planClaudeSkillDispatch", () => { |
| 43 | 43 | }); |
| 44 | 44 | }); |
| 45 | 45 | |
| 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 | 67 | it("ignores a dollar token glued to other text", () => { |
| 47 | 68 | expect(planClaudeSkillDispatch("cost is 5$implement", SKILLS)).toBeUndefined(); |
| 48 | 69 | }); |
| 49 | 70 | |
| 50 | 71 | 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 | } | |
| 54 | 81 | }); |
| 55 | 82 | }); |
apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts+4-3| ... | ... | @@ -30,7 +30,7 @@ |
| 30 | 30 | * dispatched skill are always the same set. |
| 31 | 31 | */ |
| 32 | 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 | /(^|\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; | |
| 34 | 34 | |
| 35 | 35 | export interface ClaudeSkillDispatch { |
| 36 | 36 | /** Text before the dispatched mention, or `undefined` when it opens the prompt. */ |
| ... | ... | @@ -54,7 +54,7 @@ export function planClaudeSkillDispatch( |
| 54 | 54 | const name = match[2] ?? ""; |
| 55 | 55 | if (!skillNames.has(name)) return []; |
| 56 | 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 | 59 | const last = mentions.at(-1); |
| 60 | 60 | if (!last) { |
| ... | ... | @@ -66,7 +66,8 @@ export function planClaudeSkillDispatch( |
| 66 | 66 | const leadingWithInlineSlashes = mentions |
| 67 | 67 | .slice(0, -1) |
| 68 | 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 | 71 | leading, |
| 71 | 72 | ) |
| 72 | 73 | .trimEnd(); |
apps/server/src/provider/Drivers/CursorSkills.ts+2-2| ... | ... | @@ -20,8 +20,8 @@ import { parse as parseYamlDocument } from "yaml"; |
| 20 | 20 | |
| 21 | 21 | const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/; |
| 22 | 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); | |
| 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; | |
| 24 | const HAS_SKILL_MENTION_PATTERN = new RegExp(SKILL_MENTION_PATTERN.source, "u"); | |
| 25 | 25 | const MAX_SKILL_DEPTH = 10; |
| 26 | 26 | const MAX_SKILL_BYTES = FileSystem.Size(1_000_000); |
| 27 | 27 | const MAX_SKILL_SCAN_ENTRIES = 10_000; |
apps/server/src/provider/Layers/CodexSessionRuntime.test.ts+17| ... | ... | @@ -154,6 +154,23 @@ function makeThreadOpenResponse( |
| 154 | 154 | } |
| 155 | 155 | |
| 156 | 156 | describe("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 | 174 | it("keeps invalid turn values only in the schema cause", () => { |
| 158 | 175 | const secret = "codex-turn-input-secret-sentinel"; |
| 159 | 176 | const error = Effect.runSync( |
apps/server/src/provider/Layers/CodexSessionRuntime.ts+5-1| ... | ... | @@ -605,6 +605,10 @@ function buildCodexCollaborationMode(input: { |
| 605 | 605 | }; |
| 606 | 606 | } |
| 607 | 607 | |
| 608 | // Match the skill grammar used by Claude/Cursor, leaving currency amounts as prose. | |
| 609 | const 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 | ||
| 608 | 612 | export function buildTurnStartParams(input: { |
| 609 | 613 | readonly threadId: string; |
| 610 | 614 | readonly runtimeMode: RuntimeMode; |
| ... | ... | @@ -627,7 +631,7 @@ export function buildTurnStartParams(input: { |
| 627 | 631 | if (input.prompt) { |
| 628 | 632 | turnInput.push({ |
| 629 | 633 | type: "text", |
| 630 | text: input.prompt, | |
| 634 | text: input.prompt.replace(SKILL_MENTION_PATTERN, "$1$$$2"), | |
| 631 | 635 | }); |
| 632 | 636 | } |
| 633 | 637 | for (const attachment of input.attachments ?? []) { |
apps/server/src/provider/Layers/CursorProvider.test.ts+16| ... | ... | @@ -461,6 +461,22 @@ describe("Cursor skills", () => { |
| 461 | 461 | ); |
| 462 | 462 | }); |
| 463 | 463 | |
| 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 | 480 | it("detects and invokes digit-leading Cursor skills without rewriting money", () => { |
| 465 | 481 | const names = new Set(["2spec", "20k", "100M", "1e6"]); |
| 466 | 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 | 12 | import { cn } from "~/lib/utils"; |
| 13 | 13 | |
| 14 | 14 | const 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; | |
| 16 | 16 | |
| 17 | 17 | type InlineSkill = Pick<ServerProviderSkill, "name" | "displayName">; |
| 18 | 18 | |
| ... | ... | @@ -34,7 +34,7 @@ export function SkillInlineText(props: { text: string; skills: ReadonlyArray<Inl |
| 34 | 34 | nodes.push(props.text.slice(cursor, start)); |
| 35 | 35 | } |
| 36 | 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 | } |
| 39 | 39 | |
| 40 | 40 | if (cursor === 0) { |
apps/web/src/composer-editor-mentions.test.ts+4-4| ... | ... | @@ -154,7 +154,7 @@ describe("splitPromptIntoComposerSegments", () => { |
| 154 | 154 | { type: "text", text: " " }, |
| 155 | 155 | { type: "citation", citation, source }, |
| 156 | 156 | { type: "text", text: "\n" }, |
| 157 | { type: "skill", name: "review" }, | |
| 157 | { type: "skill", name: "review", source: "$review" }, | |
| 158 | 158 | { type: "text", text: " " }, |
| 159 | 159 | { |
| 160 | 160 | type: "context-reference", |
| ... | ... | @@ -217,7 +217,7 @@ describe("splitPromptIntoComposerSegments", () => { |
| 217 | 217 | it("splits skill tokens followed by whitespace into skill segments", () => { |
| 218 | 218 | expect(splitPromptIntoComposerSegments("Use $review-follow-up please")).toEqual([ |
| 219 | 219 | { type: "text", text: "Use " }, |
| 220 | { type: "skill", name: "review-follow-up" }, | |
| 220 | { type: "skill", name: "review-follow-up", source: "$review-follow-up" }, | |
| 221 | 221 | { type: "text", text: " please" }, |
| 222 | 222 | ]); |
| 223 | 223 | }); |
| ... | ... | @@ -225,7 +225,7 @@ describe("splitPromptIntoComposerSegments", () => { |
| 225 | 225 | it("splits digit-leading skill tokens into skill segments", () => { |
| 226 | 226 | expect(splitPromptIntoComposerSegments("Use $2spec please")).toEqual([ |
| 227 | 227 | { type: "text", text: "Use " }, |
| 228 | { type: "skill", name: "2spec" }, | |
| 228 | { type: "skill", name: "2spec", source: "$2spec" }, | |
| 229 | 229 | { type: "text", text: " please" }, |
| 230 | 230 | ]); |
| 231 | 231 | }); |
| ... | ... | @@ -278,7 +278,7 @@ describe("splitPromptIntoComposerSegments", () => { |
| 278 | 278 | { type: "text", text: "Inspect " }, |
| 279 | 279 | terminalSegment, |
| 280 | 280 | { type: "text", text: " " }, |
| 281 | { type: "skill", name: "review-follow-up" }, | |
| 281 | { type: "skill", name: "review-follow-up", source: "$review-follow-up" }, | |
| 282 | 282 | { type: "text", text: " after " }, |
| 283 | 283 | { type: "mention", path: "AGENTS.md", source: "@AGENTS.md" }, |
| 284 | 284 | { type: "text", text: " " }, |
apps/web/src/composer-editor-mentions.ts+2-1| ... | ... | @@ -19,6 +19,7 @@ export type ComposerPromptSegment = |
| 19 | 19 | | { |
| 20 | 20 | type: "skill"; |
| 21 | 21 | name: string; |
| 22 | source: string; | |
| 22 | 23 | } |
| 23 | 24 | | { |
| 24 | 25 | type: "citation"; |
| ... | ... | @@ -127,7 +128,7 @@ function splitPromptTextIntoComposerSegments(text: string): ComposerPromptSegmen |
| 127 | 128 | source: match.source, |
| 128 | 129 | }); |
| 129 | 130 | } else { |
| 130 | segments.push({ type: "skill", name: match.value }); | |
| 131 | segments.push({ type: "skill", name: match.value, source: match.source }); | |
| 131 | 132 | } |
| 132 | 133 | |
| 133 | 134 | cursor = match.end; |
apps/web/src/composer-list-continuation.test.ts+6| ... | ... | @@ -48,6 +48,12 @@ describe("composer list continuation", () => { |
| 48 | 48 | expect(listContinuationForEnter("1. foo", 1)).toBeNull(); |
| 49 | 49 | }); |
| 50 | 50 | |
| 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 | 57 | it("refuses to split an inline chip", () => { |
| 52 | 58 | const value = "1. @README.md go"; |
| 53 | 59 | const cursor = value.indexOf("README") + 2; |
apps/web/src/composer-list-continuation.ts-1| ... | ... | @@ -73,7 +73,6 @@ function segmentSource( |
| 73 | 73 | segment: ReturnType<typeof splitPromptIntoComposerSegments>[number], |
| 74 | 74 | ): string { |
| 75 | 75 | if (segment.type === "text") return segment.text; |
| 76 | if (segment.type === "skill") return `$${segment.name}`; | |
| 77 | 76 | return segment.source; |
| 78 | 77 | } |
| 79 | 78 |
apps/web/src/composer-logic.test.ts+27-17| ... | ... | @@ -230,17 +230,20 @@ describe("detectComposerTrigger", () => { |
| 230 | 230 | }); |
| 231 | 231 | }); |
| 232 | 232 | |
| 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 | ); | |
| 244 | 247 | |
| 245 | 248 | it("detects a pull request number at a token boundary", () => { |
| 246 | 249 | const text = "Compare this with #8737"; |
| ... | ... | @@ -468,10 +471,10 @@ describe("expandCollapsedComposerCursor", () => { |
| 468 | 471 | expect(detectComposerTrigger(text, expandedCursor)).toBeNull(); |
| 469 | 472 | }); |
| 470 | 473 | |
| 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`; | |
| 473 | 476 | const collapsedCursorAfterSkill = "run ".length + 2; |
| 474 | const expandedCursorAfterSkill = "run $review-follow-up ".length; | |
| 477 | const expandedCursorAfterSkill = `run ${prefix}review-follow-up `.length; | |
| 475 | 478 | |
| 476 | 479 | expect(expandCollapsedComposerCursor(text, collapsedCursorAfterSkill)).toBe( |
| 477 | 480 | expandedCursorAfterSkill, |
| ... | ... | @@ -532,10 +535,10 @@ describe("collapseExpandedComposerCursor", () => { |
| 532 | 535 | expect(expandCollapsedComposerCursor(text, collapsedCursor)).toBe(expandedCursor); |
| 533 | 536 | }); |
| 534 | 537 | |
| 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`; | |
| 537 | 540 | const collapsedCursorAfterSkill = "run ".length + 2; |
| 538 | const expandedCursorAfterSkill = "run $review-follow-up ".length; | |
| 541 | const expandedCursorAfterSkill = `run ${prefix}review-follow-up `.length; | |
| 539 | 542 | |
| 540 | 543 | expect(collapseExpandedComposerCursor(text, expandedCursorAfterSkill)).toBe( |
| 541 | 544 | collapsedCursorAfterSkill, |
| ... | ... | @@ -543,6 +546,13 @@ describe("collapseExpandedComposerCursor", () => { |
| 543 | 546 | }); |
| 544 | 547 | }); |
| 545 | 548 | |
| 549 | it("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 | ||
| 546 | 556 | describe("clampCollapsedComposerCursor", () => { |
| 547 | 557 | it("clamps to collapsed prompt length when mentions are present", () => { |
| 548 | 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 | 88 | continue; |
| 89 | 89 | } |
| 90 | 90 | if (segment.type === "skill") { |
| 91 | const expandedLength = segment.name.length + 1; | |
| 91 | const expandedLength = segment.source.length; | |
| 92 | 92 | if (remaining <= 1) { |
| 93 | 93 | return expandedCursor + (remaining === 0 ? 0 : expandedLength); |
| 94 | 94 | } |
| ... | ... | @@ -164,7 +164,7 @@ export function collapseExpandedComposerCursor(text: string, cursorInput: number |
| 164 | 164 | continue; |
| 165 | 165 | } |
| 166 | 166 | if (segment.type === "skill") { |
| 167 | const expandedLength = segment.name.length + 1; | |
| 167 | const expandedLength = segment.source.length; | |
| 168 | 168 | if (remaining === 0) { |
| 169 | 169 | return collapsedCursor; |
| 170 | 170 | } |
| ... | ... | @@ -244,10 +244,11 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos |
| 244 | 244 | rangeEnd: cursor, |
| 245 | 245 | }; |
| 246 | 246 | } |
| 247 | if (token.startsWith("$")) { | |
| 247 | const skillPrefix = /^\p{Sc}/u.exec(token); | |
| 248 | if (skillPrefix) { | |
| 248 | 249 | return { |
| 249 | 250 | kind: "skill", |
| 250 | query: token.slice(1), | |
| 251 | query: token.slice(skillPrefix[0].length), | |
| 251 | 252 | rangeStart: tokenStart, |
| 252 | 253 | rangeEnd: cursor, |
| 253 | 254 | }; |
apps/web/src/composer-rich-text-doc.test.ts+10| ... | ... | @@ -118,6 +118,16 @@ function roundTripPlain(value: string) { |
| 118 | 118 | } |
| 119 | 119 | |
| 120 | 120 | describe("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 | 131 | it.each([ |
| 122 | 132 | "", |
| 123 | 133 | "\n\n", |
apps/web/src/providerSkillSearch.test.ts+8| ... | ... | @@ -13,6 +13,14 @@ function makeSkill(input: Partial<ServerProviderSkill> & Pick<ServerProviderSkil |
| 13 | 13 | } |
| 14 | 14 | |
| 15 | 15 | describe("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 | 24 | it("moves exact ui matches ahead of broader ui matches", () => { |
| 17 | 25 | const skills = [ |
| 18 | 26 | makeSkill({ |
apps/web/src/providerSkillSearch.ts+1-1| ... | ... | @@ -75,7 +75,7 @@ export function searchProviderSkills( |
| 75 | 75 | limit = Number.POSITIVE_INFINITY, |
| 76 | 76 | ): ServerProviderSkill[] { |
| 77 | 77 | const enabledSkills = dedupeProviderSkillsByName(skills.filter(isProviderSkillUserInvocable)); |
| 78 | const normalizedQuery = normalizeSearchQuery(query, { trimLeadingPattern: /^\$+/ }); | |
| 78 | const normalizedQuery = normalizeSearchQuery(query, { trimLeadingPattern: /^\p{Sc}+/u }); | |
| 79 | 79 | |
| 80 | 80 | if (!normalizedQuery) { |
| 81 | 81 | return enabledSkills; |
packages/shared/src/composerInlineTokens.test.ts+21-10| ... | ... | @@ -31,16 +31,27 @@ 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 | ]); | |
| 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([]); | |
| 44 | 55 | }); |
| 45 | 56 | |
| 46 | 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 | 26 | * contain at least one letter. |
| 27 | 27 | */ |
| 28 | 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; | |
| 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; | |
| 30 | 30 | const MENTION_TOKEN_REGEX = /(^|\s)@(?:"((?:\\.|[^"\\])*)"|([^\s@"]+))(?=\s)/g; |
| 31 | 31 | /** |
| 32 | 32 | * The label body is bounded rather than `*`. Unbounded, every whitespace in |
packages/shared/src/composerTrigger.test.ts+16-1| ... | ... | @@ -1,6 +1,21 @@ |
| 1 | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | |
| 3 | import { serializeComposerFileLink } from "./composerTrigger.ts"; | |
| 3 | import { detectComposerTrigger, serializeComposerFileLink } from "./composerTrigger.ts"; | |
| 4 | ||
| 5 | describe("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 | }); | |
| 4 | 19 | |
| 5 | 20 | describe("serializeComposerFileLink", () => { |
| 6 | 21 | it("uses the basename as the markdown label", () => { |
packages/shared/src/composerTrigger.ts+3-2| ... | ... | @@ -108,10 +108,11 @@ export function detectComposerTrigger( |
| 108 | 108 | rangeStart: tokenStart, |
| 109 | 109 | rangeEnd: cursor, |
| 110 | 110 | }; |
| 111 | if (token.startsWith("$")) { | |
| 111 | const skillPrefix = /^\p{Sc}/u.exec(token); | |
| 112 | if (skillPrefix) { | |
| 112 | 113 | return { |
| 113 | 114 | kind: "skill", |
| 114 | query: token.slice(1), | |
| 115 | query: token.slice(skillPrefix[0].length), | |
| 115 | 116 | rangeStart: tokenStart, |
| 116 | 117 | rangeEnd: cursor, |
| 117 | 118 | }; |