| 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,7 +319,7 @@ function appendRun( |
| 319 | } | 319 | } |
| 320 | 320 | ||
| 321 | const SKILL_TOKEN_REGEX = | 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 | function formatSkillLabel(skill: SelectableMarkdownSkill): string { | 324 | function 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 = selected | 155 | 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 | }); |
| 371 | 371 | ||
| 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 | }); |
| 309 | 309 | ||
| 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 | ]); |
| 320 | 333 | }, | |
| 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 | }); | ||
| 332 | 335 | ||
| 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 | }); |
| 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 | 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 | }); |
| 49 | 70 | ||
| 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 | */ |
| 32 | const SKILL_MENTION_PATTERN = | 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 | export interface ClaudeSkillDispatch { | 35 | export 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 = mentions | 66 | 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"; |
| 20 | 20 | ||
| 21 | const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/; | 21 | const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/; |
| 22 | const SKILL_MENTION_PATTERN = | 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; | 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); | 24 | const HAS_SKILL_MENTION_PATTERN = new RegExp(SKILL_MENTION_PATTERN.source, "u"); |
| 25 | const MAX_SKILL_DEPTH = 10; | 25 | const MAX_SKILL_DEPTH = 10; |
| 26 | const MAX_SKILL_BYTES = FileSystem.Size(1_000_000); | 26 | const MAX_SKILL_BYTES = FileSystem.Size(1_000_000); |
| 27 | const MAX_SKILL_SCAN_ENTRIES = 10_000; | 27 | const 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 | } |
| 155 | 155 | ||
| 156 | describe("buildTurnStartParams", () => { | 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 | 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 | } |
| 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 | export function buildTurnStartParams(input: { | 612 | export 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 | }); |
| 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 | 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 { |
| 12 | import { cn } from "~/lib/utils"; | 12 | import { cn } from "~/lib/utils"; |
| 13 | 13 | ||
| 14 | const SKILL_TOKEN_REGEX = | 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 | type InlineSkill = Pick<ServerProviderSkill, "name" | "displayName">; | 17 | type InlineSkill = Pick<ServerProviderSkill, "name" | "displayName">; |
| 18 | 18 | ||
| ... | @@ -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 | } |
| 39 | 39 | ||
| 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 | } |
| 132 | 133 | ||
| 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 | }); |
| 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 | 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 | } |
| 79 | 78 |
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 | }); |
| 232 | 232 | ||
| 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) => { |
| 236 | 236 | 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 | ); | ||
| 244 | 247 | ||
| 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 | }); |
| 470 | 473 | ||
| 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; |
| 475 | 478 | ||
| 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 | }); |
| 534 | 537 | ||
| 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; |
| 539 | 542 | ||
| 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 | }); |
| 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 | describe("clampCollapsedComposerCursor", () => { | 556 | describe("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 | } |
| 119 | 119 | ||
| 120 | describe("composer rich text document model", () => { | 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 | 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 | } |
| 14 | 14 | ||
| 15 | describe("searchProviderSkills", () => { | 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 | 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 }); |
| 79 | 79 | ||
| 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 | }); |
| 33 | 33 | ||
| 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 | }); |
| 45 | 56 | ||
| 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 | */ |
| 28 | const SKILL_TOKEN_REGEX = | 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 | const MENTION_TOKEN_REGEX = /(^|\s)@(?:"((?:\\.|[^"\\])*)"|([^\s@"]+))(?=\s)/g; | 30 | const MENTION_TOKEN_REGEX = /(^|\s)@(?:"((?:\\.|[^"\\])*)"|([^\s@"]+))(?=\s)/g; |
| 31 | /** | 31 | /** |
| 32 | * The label body is bounded rather than `*`. Unbounded, every whitespace in | 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,6 +1,21 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 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 | describe("serializeComposerFileLink", () => { | 20 | describe("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 | }; |