diff --git a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts index 05d629fac2..79075326e1 100644 --- a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts +++ b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts @@ -114,7 +114,7 @@ export function nativeMarkdownContextCopyRanges( const source = reference ? formatComposerContextReference({ ...reference, label: run.text }) : run.skillName - ? `$${run.skillName}` + ? (run.sourceText ?? `$${run.skillName}`) : run.fileIcon && run.href ? (run.sourceText ?? `[${run.text}](<${run.href}>)`) : null; @@ -267,6 +267,7 @@ function sameRunStyle(left: NativeMarkdownTextRun, right: NativeMarkdownTextRun) left.externalHost === right.externalHost && left.fileIcon === right.fileIcon && left.skillName === right.skillName && + left.sourceText === right.sourceText && left.skillLabel === right.skillLabel && left.role === right.role && left.headingLevel === right.headingLevel && @@ -319,7 +320,7 @@ function appendRun( } 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; + /(^|\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; function formatSkillLabel(skill: SelectableMarkdownSkill): string { const displayName = skill.displayName?.trim(); @@ -336,6 +337,7 @@ function formatSkillLabel(skill: SelectableMarkdownSkill): string { function decorateSkillRuns( runs: ReadonlyArray, skills: ReadonlyArray, + allowUnicodeSkillAliases: boolean, ): ReadonlyArray { if (skills.length === 0) { return runs; @@ -359,13 +361,15 @@ function decorateSkillRuns( continue; } const start = (match.index ?? 0) + prefix.length; - const end = start + name.length + 1; + const end = (match.index ?? 0) + match[0].length; + if (!allowUnicodeSkillAliases && run.text[start] !== "$") continue; if (start > cursor) { decorated.push({ ...run, text: run.text.slice(cursor, start) }); } decorated.push({ ...run, text: run.text.slice(start, end), + sourceText: run.text.slice(start, end), skillName: name, skillLabel: formatSkillLabel(skill), }); @@ -949,6 +953,7 @@ export function nativeMarkdownChunkSpacing( export function nativeMarkdownDocumentRuns( node: MarkdownNode, skills: ReadonlyArray = [], + allowUnicodeSkillAliases = false, ): ReadonlyArray { const runs = appendDocumentBlock([], node); while (runs.length > 0) { @@ -964,5 +969,5 @@ export function nativeMarkdownDocumentRuns( runs[lastIndex] = { ...last, text }; } } - return decorateMentionRuns(decorateSkillRuns(runs, skills)); + return decorateMentionRuns(decorateSkillRuns(runs, skills, allowUnicodeSkillAliases)); } diff --git a/apps/mobile/src/components/ComposerEditor.tsx b/apps/mobile/src/components/ComposerEditor.tsx index a342bdcea9..9ff88dc847 100644 --- a/apps/mobile/src/components/ComposerEditor.tsx +++ b/apps/mobile/src/components/ComposerEditor.tsx @@ -122,8 +122,9 @@ export function ComposerEditor({ const selectedReference = selected ? collectComposerContextReferences(selected.source)[0] : undefined; - const selectedSkill = selected?.source.startsWith("$") - ? props.skills?.find((skill) => skill.name === selected.source.slice(1)) + const selectedSkillName = selected?.source.match(/^\p{Sc}(.+)$/u)?.[1]; + const selectedSkill = selectedSkillName + ? props.skills?.find((skill) => skill.name === selectedSkillName) : undefined; const record = draft.context?.records.find( (entry) => entry.contextId === selectedReference?.contextId, diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index dd37975fb0..4bf29f849c 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -1,3 +1,4 @@ +import { supportsUnicodeSkillAliases } from "@t3tools/client-runtime/providerSkills"; import { shouldHandleUsageLimitsCommand } from "@t3tools/shared/usageLimits"; import { useAtomValue } from "@effect/atom-react"; import * as Cause from "effect/Cause"; @@ -1265,6 +1266,7 @@ export function NewTaskDraftScreen(props: { const promptEditor = ( <> { diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.test.ts b/apps/mobile/src/features/threads/use-composer-command-menu.test.ts index f33caa86e5..3a17e1ea08 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.test.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.test.ts @@ -220,6 +220,24 @@ describe("buildComposerCommandItems path results", () => { }); describe("composerCommandReplacement", () => { + it.each(["$", "€", "£", "₹", "𑿝"])( + "preserves the %s trigger prefix when selecting a native skill", + (prefix) => { + expect( + composerCommandReplacement( + { + id: "skill:review-t3-upstream", + type: "skill", + skill: skill({ name: "review-t3-upstream" }), + label: "Review T3 Upstream", + description: "", + }, + `${prefix}review`, + ), + ).toBe(`${prefix}review-t3-upstream `); + }, + ); + it("writes a markdown link for a path and a skill token for a skill", () => { expect( composerCommandReplacement({ diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.ts b/apps/mobile/src/features/threads/use-composer-command-menu.ts index 0b67cb6091..29718e3201 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -7,6 +7,7 @@ import { getProviderSkillsForSlashMenu, getProviderSlashCommandsForSlashMenu, isProviderSkillUserInvocable, + supportsUnicodeSkillAliases, } from "@t3tools/client-runtime/providerSkills"; import type { ComposerPathSearchEntry } from "@t3tools/client-runtime/state/threads"; import { @@ -212,7 +213,7 @@ export function buildComposerCommandItems({ (selectedProviderStatus?.skills ?? []).filter(isProviderSkillUserInvocable), ); const normalizedQuery = normalizeSearchQuery(trigger.query, { - trimLeadingPattern: /^\$+/, + trimLeadingPattern: /^\p{Sc}+/u, }); if (!normalizedQuery) { @@ -312,12 +313,13 @@ export function buildComposerCommandItems({ /** The text a selected item writes over its trigger range, or `null` for mode switches. */ export function composerCommandReplacement( item: Exclude, + originalToken?: string, ): string | null { switch (item.type) { case "path": return `${serializeComposerFileLink(item.path)} `; case "skill": - return `$${item.skill.name} `; + return `${/^\p{Sc}/u.exec(originalToken ?? "")?.[0] ?? "$"}${item.skill.name} `; case "slash-command": return item.command === "plan" || item.command === "default" ? null : `/${item.command} `; case "provider-slash-command": @@ -467,8 +469,13 @@ export function useComposerCommandMenu({ if (!enabled || selection.start !== selection.end) { return null; } - return detectComposerTrigger(draftMessage, selection.end); - }, [draftMessage, enabled, selection]); + return detectComposerTrigger( + draftMessage, + selection.end, + undefined, + supportsUnicodeSkillAliases(selectedProviderStatus?.driver), + ); + }, [draftMessage, enabled, selection, selectedProviderStatus?.driver]); const pathSearch = useComposerPathSearch({ environmentId, @@ -554,7 +561,10 @@ export function useComposerCommandMenu({ return; } - const replacement = composerCommandReplacement(item); + const replacement = composerCommandReplacement( + item, + draftMessage.slice(trigger.rangeStart, trigger.rangeEnd), + ); const result = replaceTextRange( draftMessage, trigger.rangeStart, diff --git a/apps/mobile/src/lib/nativeMarkdownText.test.ts b/apps/mobile/src/lib/nativeMarkdownText.test.ts index 4b998d0c88..3037619728 100644 --- a/apps/mobile/src/lib/nativeMarkdownText.test.ts +++ b/apps/mobile/src/lib/nativeMarkdownText.test.ts @@ -307,27 +307,56 @@ describe("nativeMarkdownDocumentRuns", () => { ]); }); - it("decorates known skill references as selectable skill links", () => { - const node: MarkdownNode = { - type: "document", - children: [ + it.each(["$", "€", "£", "¥", "₹", "₩", "₿", "𑿝"])( + "decorates %s skill references as selectable skill links", + (prefix) => { + const node: MarkdownNode = { + type: "document", + children: [ + { + type: "paragraph", + children: [{ type: "text", content: `Use ${prefix}ui for this.` }], + }, + ], + }; + + expect(nativeMarkdownDocumentRuns(node, [{ name: "ui", displayName: "UI" }], true)).toEqual([ + { text: "Use ", role: "body" }, { - type: "paragraph", - children: [{ type: "text", content: "Use $ui for this." }], + text: `${prefix}ui`, + role: "body", + sourceText: `${prefix}ui`, + skillName: "ui", + skillLabel: "UI", }, - ], + { text: " for this.", role: "body" }, + ]); + }, + ); + + it("keeps Unicode aliases literal in history without provider identity", () => { + const node: MarkdownNode = { + type: "document", + children: [{ type: "paragraph", children: [{ type: "text", content: "Use 𑿝ui and $ui" }] }], }; + const runs = nativeMarkdownDocumentRuns(node, [{ name: "ui" }]); + expect(runs.some((run) => run.skillName === "ui" && run.text === "𑿝ui")).toBe(false); + expect(runs.some((run) => run.skillName === "ui" && run.text === "$ui")).toBe(true); + expect(runs.map((run) => run.text).join("")).toBe("Use 𑿝ui and $ui"); + }); - expect(nativeMarkdownDocumentRuns(node, [{ name: "ui", displayName: "UI" }])).toEqual([ - { text: "Use ", role: "body" }, - { - text: "$ui", - role: "body", - skillName: "ui", - skillLabel: "UI", - }, - { text: " for this.", role: "body" }, - ]); + it("copies a decorated astral currency alias without replacing its prefix", () => { + const node: MarkdownNode = { + type: "document", + children: [{ type: "paragraph", children: [{ type: "text", content: "Use 𑿝ui now" }] }], + }; + const skillRun = nativeMarkdownDocumentRuns(node, [{ name: "ui" }], true).find( + (run) => run.skillName === "ui", + ); + expect(skillRun).toBeDefined(); + expect( + nativeMarkdownContextCopyRanges([{ run: skillRun!, text: "\uFFFC", inlineImageLength: 0 }]), + ).toEqual([{ start: 0, end: 1, text: "𑿝ui" }]); }); it("decorates known skill references that begin with a digit", () => { @@ -346,6 +375,7 @@ describe("nativeMarkdownDocumentRuns", () => { { text: "$2spec", role: "body", + sourceText: "$2spec", skillName: "2spec", skillLabel: "2Spec", }, @@ -364,9 +394,12 @@ describe("nativeMarkdownDocumentRuns", () => { ], }; - expect(nativeMarkdownDocumentRuns(node, [{ name: "ui", displayName: "UI" }])).toContainEqual({ + expect( + nativeMarkdownDocumentRuns(node, [{ name: "ui", displayName: "UI" }], true), + ).toContainEqual({ text: "$ui", role: "body", + sourceText: "$ui", skillName: "ui", skillLabel: "UI", }); diff --git a/apps/mobile/src/native/T3ComposerEditor.ios.tsx b/apps/mobile/src/native/T3ComposerEditor.ios.tsx index a9b3111aa6..7f69acc26a 100644 --- a/apps/mobile/src/native/T3ComposerEditor.ios.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.ios.tsx @@ -122,7 +122,16 @@ export function ComposerEditor({ // first controlled payload must be a non-echo so a restored draft (or a // recycled native view) is applied rather than skipped. const nativeEventSnapshotsRef = useRef([]); - const confirmedTokensRef = useRef(collectComposerInlineTokens(props.value)); + const confirmedTokensRef = useRef( + collectComposerInlineTokens(props.value, { + allowUnicodeSkillAliases: props.allowUnicodeSkillAliases ?? false, + unicodeSkillNames: new Set( + skills + .filter((skill) => skill.enabled !== false && skill.userInvocable !== false) + .map((skill) => skill.name), + ), + }), + ); const bodyText = useScaledTextRole("body"); const theme = useUniwindTheme(); const fontFamily = useFontFamily("regular"); @@ -145,6 +154,12 @@ export function ComposerEditor({ const tokensJson = useMemo(() => { const tokens = collectComposerInlineTokens(props.value, { preserveTrailingFrom: confirmedTokensRef.current, + allowUnicodeSkillAliases: props.allowUnicodeSkillAliases ?? false, + unicodeSkillNames: new Set( + skills + .filter((skill) => skill.enabled !== false && skill.userInvocable !== false) + .map((skill) => skill.name), + ), }); confirmedTokensRef.current = tokens; return JSON.stringify( @@ -178,7 +193,7 @@ export function ComposerEditor({ }; }), ); - }, [props.value, props.context, skillLabels]); + }, [props.value, props.context, props.allowUnicodeSkillAliases, skillLabels, skills]); // Every render resolves against the snapshot history, so a render whose // (value, selection) lags the acknowledged native state is stamped behind // the native revision and rejected by the editor instead of re-applying a diff --git a/apps/mobile/src/native/T3ComposerEditor.native.tsx b/apps/mobile/src/native/T3ComposerEditor.native.tsx index 9aa7e9e728..7c715d0039 100644 --- a/apps/mobile/src/native/T3ComposerEditor.native.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.native.tsx @@ -124,7 +124,16 @@ export function ComposerEditor({ // first controlled payload must be a non-echo so a restored draft (or a // recycled native view) is applied rather than skipped. const nativeEventSnapshotsRef = useRef([]); - const [initialConfirmedTokens] = useState(() => collectComposerInlineTokens(props.value)); + const [initialConfirmedTokens] = useState(() => + collectComposerInlineTokens(props.value, { + allowUnicodeSkillAliases: props.allowUnicodeSkillAliases ?? false, + unicodeSkillNames: new Set( + skills + .filter((skill) => skill.enabled !== false && skill.userInvocable !== false) + .map((skill) => skill.name), + ), + }), + ); const confirmedTokensRef = useRef(initialConfirmedTokens); const theme = useUniwindTheme(); const handlePaste = useNativePaste((uris) => onPasteImages?.(uris)); @@ -147,6 +156,12 @@ export function ComposerEditor({ const tokensJson = useMemo(() => { const tokens = collectComposerInlineTokens(props.value, { preserveTrailingFrom: confirmedTokensRef.current, + allowUnicodeSkillAliases: props.allowUnicodeSkillAliases ?? false, + unicodeSkillNames: new Set( + skills + .filter((skill) => skill.enabled !== false && skill.userInvocable !== false) + .map((skill) => skill.name), + ), }); confirmedTokensRef.current = tokens; return JSON.stringify( @@ -180,7 +195,7 @@ export function ComposerEditor({ }; }), ); - }, [props.value, props.context, skillLabels]); + }, [props.value, props.context, props.allowUnicodeSkillAliases, skillLabels, skills]); // Every render resolves against the snapshot history, so a render whose // (value, selection) lags the acknowledged native state is stamped behind // the native revision and rejected by the editor instead of re-applying a diff --git a/apps/mobile/src/native/T3ComposerEditor.types.ts b/apps/mobile/src/native/T3ComposerEditor.types.ts index dc1448e87d..365dfbf526 100644 --- a/apps/mobile/src/native/T3ComposerEditor.types.ts +++ b/apps/mobile/src/native/T3ComposerEditor.types.ts @@ -16,6 +16,7 @@ export interface ComposerEditorHandle { export interface ComposerEditorProps { readonly ref?: Ref; readonly value: string; + readonly allowUnicodeSkillAliases?: boolean; readonly context?: OrchestrationMessageContext; readonly clipboardFragment?: string; readonly onPasteContext?: (clipboard: { @@ -25,7 +26,7 @@ export interface ComposerEditorProps { }) => void; readonly skills?: ReadonlyArray< Pick & - Partial> + Partial> >; readonly selection?: ComposerEditorSelection; readonly placeholder?: string; diff --git a/apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts index e6e6da9d5e..acb7809c3a 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts @@ -43,13 +43,40 @@ describe("planClaudeSkillDispatch", () => { }); }); + it("dispatches currency-prefixed mentions and preserves their source boundaries", () => { + for (const symbol of ["€", "£", "¥", "₹", "₩", "₿", "𑿝"]) { + expect( + planClaudeSkillDispatch( + `${symbol}review the diff, then ${symbol}implement the fixes`, + SKILLS, + ), + ).toEqual({ + leadingText: "/review the diff, then", + commandText: "/implement the fixes", + skillName: "implement", + }); + expect(planClaudeSkillDispatch(`${symbol}2spec for this`, SKILLS)).toEqual({ + leadingText: undefined, + commandText: "/2spec for this", + skillName: "2spec", + }); + expect(planClaudeSkillDispatch(`5${symbol}review ${symbol}unknown`, SKILLS)).toBeUndefined(); + } + }); + 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(); + const skillsWithCurrency = new Set([...SKILLS, "20", "20k", "100M", "1e6"]); + for (const symbol of ["$", "€", "£", "¥", "₹", "₩", "₿", "𑿝"]) { + expect( + planClaudeSkillDispatch( + `pay ${symbol}20 ${symbol}20k ${symbol}100M ${symbol}1e6 tomorrow`, + skillsWithCurrency, + ), + ).toBeUndefined(); + } }); }); diff --git a/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts index 27e7dcf68b..2dc106e853 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts @@ -30,7 +30,7 @@ * dispatched skill are always the same set. */ 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; + /(^|\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; export interface ClaudeSkillDispatch { /** Text before the dispatched mention, or `undefined` when it opens the prompt. */ @@ -54,7 +54,7 @@ export function planClaudeSkillDispatch( const name = match[2] ?? ""; if (!skillNames.has(name)) return []; const start = (match.index ?? 0) + (match[1]?.length ?? 0); - return [{ name, start, end: start + name.length + 1 }]; + return [{ name, start, end: (match.index ?? 0) + match[0].length }]; }); const last = mentions.at(-1); if (!last) { @@ -66,7 +66,8 @@ export function planClaudeSkillDispatch( const leadingWithInlineSlashes = mentions .slice(0, -1) .reduceRight( - (text, mention) => `${text.slice(0, mention.start)}/${text.slice(mention.start + 1)}`, + (text, mention) => + `${text.slice(0, mention.start)}/${mention.name}${text.slice(mention.end)}`, leading, ) .trimEnd(); diff --git a/apps/server/src/provider/Drivers/CursorSkills.ts b/apps/server/src/provider/Drivers/CursorSkills.ts index b2f9872f79..c655fe0f16 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)\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; +const HAS_SKILL_MENTION_PATTERN = new RegExp(SKILL_MENTION_PATTERN.source, "u"); 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/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index 3a509c4f08..788b282952 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -1,8 +1,11 @@ import * as NodeAssert from "node:assert/strict"; import { it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; import { describe } from "vite-plus/test"; import { DEFAULT_MODEL, ThreadId } from "@t3tools/contracts"; import * as CodexErrors from "effect-codex-app-server/errors"; @@ -13,6 +16,8 @@ import { buildCodexDeveloperInstructions } from "../CodexDeveloperInstructions.t import { codexSessionAppServerArgs } from "./codexLaunchArgs.ts"; import { buildTurnStartParams, + codexSkillNamesForCwd, + resolveCodexSkillNamesForPrompt, describeMcpElicitation, hasConfiguredMcpServer, isRecoverableThreadResumeError, @@ -155,6 +160,136 @@ function makeThreadOpenResponse( } describe("buildTurnStartParams", () => { + it.effect("cancels a stalled catalog lookup and preserves the Unicode prompt", () => + Effect.gen(function* () { + const response = yield* Deferred.make<{ + readonly data: ReadonlyArray<{ + readonly cwd: string; + readonly skills: ReadonlyArray<{ readonly name: string; readonly enabled: boolean }>; + }>; + }>(); + let interrupted = false; + const request = Deferred.await(response).pipe( + Effect.onInterrupt(() => + Effect.sync(() => { + interrupted = true; + }), + ), + ); + const lookup = yield* resolveCodexSkillNamesForPrompt("€review", "/project", request).pipe( + Effect.forkScoped, + ); + yield* TestClock.adjust("2 seconds"); + NodeAssert.equal(yield* Fiber.join(lookup), undefined); + NodeAssert.equal(interrupted, true); + yield* Deferred.succeed(response, { + data: [{ cwd: "/project", skills: [{ name: "review", enabled: true }] }], + }); + NodeAssert.equal(yield* Fiber.join(lookup), undefined); + + const turn = yield* buildTurnStartParams({ + threadId: "provider-thread-1", + runtimeMode: "full-access", + prompt: "€review", + }); + NodeAssert.deepEqual(turn.input, [{ type: "text", text: "€review" }]); + }), + ); + + it.effect("leaves literal text when the catalog request fails", () => + Effect.gen(function* () { + const names = yield* resolveCodexSkillNamesForPrompt( + "€review", + "/project", + Effect.fail( + new CodexErrors.CodexAppServerRequestError({ + code: -32603, + errorMessage: "catalog unavailable", + }), + ), + ); + NodeAssert.equal(names, undefined); + }), + ); + it("uses only the requested Codex cwd, allowing a sole canonicalized response cwd", () => { + NodeAssert.deepEqual( + [ + ...codexSkillNamesForCwd( + { data: [{ cwd: "/canonical/project", skills: [{ name: "review", enabled: true }] }] }, + "/symlink/project", + ), + ], + ["review"], + ); + NodeAssert.deepEqual( + [ + ...codexSkillNamesForCwd( + { + data: [ + { cwd: "/other", skills: [{ name: "private", enabled: true }] }, + { cwd: "/requested", skills: [{ name: "review", enabled: true }] }, + ], + }, + "/requested", + ), + ], + ["review"], + ); + NodeAssert.deepEqual( + [ + ...codexSkillNamesForCwd( + { + data: [ + { cwd: "/other", skills: [{ name: "private", enabled: true }] }, + { cwd: "/different", skills: [{ name: "review", enabled: true }] }, + ], + }, + "/requested", + ), + ], + [], + ); + }); + it.effect("sends currency skill aliases in Codex's canonical dollar form", () => + Effect.gen(function* () { + for (const symbol of ["€", "£", "¥", "₹", "₩", "₿", "𑿝"]) { + const prose = `${symbol}20 ${symbol}20k ${symbol}100M ${symbol}1e6 5${symbol}review`; + const params = yield* buildTurnStartParams({ + threadId: "provider-thread-1", + runtimeMode: "full-access", + prompt: `${symbol}review ${symbol}2spec $existing ${prose} ${symbol}last`, + skillNames: new Set(["review", "2spec", "last"]), + }); + + NodeAssert.deepEqual(params.input, [ + { type: "text", text: `$review $2spec $existing ${prose} $last` }, + ]); + } + }), + ); + + it.effect("preserves unknown Unicode skill words and catalog-unavailable prompts", () => + Effect.gen(function* () { + const prompt = "Use €review and €unknown with $existing"; + const known = yield* buildTurnStartParams({ + threadId: "provider-thread-1", + runtimeMode: "full-access", + prompt, + skillNames: new Set(["review"]), + }); + NodeAssert.deepEqual(known.input, [ + { type: "text", text: "Use $review and €unknown with $existing" }, + ]); + + const unavailable = yield* buildTurnStartParams({ + threadId: "provider-thread-1", + runtimeMode: "full-access", + prompt, + }); + NodeAssert.deepEqual(unavailable.input, [{ type: "text", text: prompt }]); + }), + ); + it("keeps invalid turn values only in the schema cause", () => { const secret = "codex-turn-input-secret-sentinel"; const error = Effect.runSync( diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index ade2386346..f53cdc6876 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -644,10 +644,70 @@ function buildCodexCollaborationMode(input: { }; } +// Match the skill grammar used by Claude/Cursor, leaving currency amounts as prose. +const SKILL_MENTION_PATTERN = + /(^|\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; + +function hasUnicodeSkillMention(prompt: string): boolean { + return [...prompt.matchAll(SKILL_MENTION_PATTERN)].some((match) => { + const prefix = match[1] ?? ""; + return match[0].slice(prefix.length, prefix.length + 1) !== "$"; + }); +} + +export function codexSkillNamesForCwd( + response: { + readonly data: ReadonlyArray<{ + readonly cwd: string; + readonly skills: ReadonlyArray<{ readonly name: string; readonly enabled: boolean }>; + }>; + }, + cwd: string, +): ReadonlySet { + const entry = + response.data.find((item) => item.cwd === cwd) ?? + (response.data.length === 1 ? response.data[0] : undefined); + return new Set((entry?.skills ?? []).filter((skill) => skill.enabled).map((skill) => skill.name)); +} + +export function resolveCodexSkillNamesForPrompt( + prompt: string | undefined, + cwd: string, + request: Effect.Effect< + { + readonly data: ReadonlyArray<{ + readonly cwd: string; + readonly skills: ReadonlyArray<{ readonly name: string; readonly enabled: boolean }>; + }>; + }, + E + >, +): Effect.Effect | undefined> { + if (!prompt || !hasUnicodeSkillMention(prompt)) return Effect.succeed(undefined); + return request.pipe( + Effect.timeoutOption("2 seconds"), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.logWarning("Timed out resolving Codex skill aliases before turn.").pipe( + Effect.as(undefined), + ), + onSome: (response) => Effect.succeed(codexSkillNamesForCwd(response, cwd)), + }), + ), + Effect.catch((cause) => + Effect.logWarning("Failed to resolve Codex skill aliases before turn.", { cause }).pipe( + Effect.as(undefined), + ), + ), + ); +} + export function buildTurnStartParams(input: { readonly threadId: string; readonly runtimeMode: RuntimeMode; readonly prompt?: string; + readonly skillNames?: ReadonlySet; readonly attachments?: ReadonlyArray<{ readonly type: "localImage"; readonly path: string; @@ -666,7 +726,13 @@ export function buildTurnStartParams(input: { if (input.prompt) { turnInput.push({ type: "text", - text: input.prompt, + text: input.prompt.replace( + SKILL_MENTION_PATTERN, + (source, whitespace: string, name: string) => + source.slice(whitespace.length).startsWith("$") || input.skillNames?.has(name) + ? `${whitespace}$${name}` + : source, + ), }); } for (const attachment of input.attachments ?? []) { @@ -2925,10 +2991,18 @@ export const makeCodexSessionRuntime = ( const normalizedModel = normalizeCodexModelSlug( input.model ?? (yield* Ref.get(sessionRef)).model, ); + // Resolve aliases through this session's app server. Unknown words and + // failed catalog reads must remain the user's literal prompt text. + const skillNames = yield* resolveCodexSkillNamesForPrompt( + input.input, + options.cwd, + client.request("skills/list", { cwds: [options.cwd] }), + ); const params = yield* buildTurnStartParams({ threadId: providerThreadId, runtimeMode: options.runtimeMode, ...(input.input ? { prompt: input.input } : {}), + ...(skillNames ? { skillNames } : {}), ...(input.attachments ? { attachments: input.attachments } : {}), ...(normalizedModel ? { model: normalizedModel } : {}), ...(input.serviceTier ? { serviceTier: input.serviceTier } : {}), diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index dd314b0ec9..095d5f6cab 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -459,6 +459,22 @@ describe("Cursor skills", () => { "please /review this", ); }); + + it("rewrites Unicode currency skill aliases but leaves monetary amounts intact", () => { + const names = new Set(["review", "2spec", "20k", "100M", "1e6"]); + for (const symbol of ["€", "£", "¥", "₹", "₩", "₿", "𑿝"]) { + expect(hasCursorSkillMention(`please ${symbol}review this`)).toBe(true); + expect(rewriteCursorSkillMentions(`${symbol}review then ${symbol}2spec this`, names)).toBe( + "/review then /2spec this", + ); + const money = `${symbol}20 ${symbol}20k ${symbol}100M ${symbol}1e6`; + expect(hasCursorSkillMention(money)).toBe(false); + expect(rewriteCursorSkillMentions(money, names)).toBe(money); + expect(rewriteCursorSkillMentions(`5${symbol}review ${symbol}unknown`, names)).toBe( + `5${symbol}review ${symbol}unknown`, + ); + } + }); }); describe("buildCursorProviderSnapshot", () => { diff --git a/apps/web/src/components/ComposerPromptEditor.serialization.test.tsx b/apps/web/src/components/ComposerPromptEditor.serialization.test.tsx index 263c837568..22f168ab6f 100644 --- a/apps/web/src/components/ComposerPromptEditor.serialization.test.tsx +++ b/apps/web/src/components/ComposerPromptEditor.serialization.test.tsx @@ -30,13 +30,19 @@ vi.mock("@lexical/react/LexicalPlainTextPlugin", () => ({ let renderer: ReactTestRenderer | undefined; const editorRef = createRef(); -function composer(value: string) { +function composer(value: string, allowUnicodeSkillAliases = true, registered = true) { return ( {}} @@ -46,10 +52,10 @@ function composer(value: string) { ); } -async function renderPrompt(value: string) { +async function renderPrompt(value: string, allowUnicodeSkillAliases = true, registered = true) { await act(() => { - if (renderer) renderer.update(composer(value)); - else renderer = create(composer(value)); + if (renderer) renderer.update(composer(value, allowUnicodeSkillAliases, registered)); + else renderer = create(composer(value, allowUnicodeSkillAliases, registered)); }); } @@ -61,6 +67,14 @@ function $firstMention() { return mention; } +function $firstSkill() { + const paragraph = $getRoot().getFirstChildOrThrow(); + if (!$isElementNode(paragraph)) throw new Error("Expected a composer paragraph"); + const skill = paragraph.getFirstChildOrThrow(); + if (skill.getType() !== "composer-skill") throw new Error("Expected a skill"); + return skill; +} + class TestClipboardEvent extends Event { readonly clipboardData: DataTransfer; @@ -192,3 +206,87 @@ describe("composer mention serialization", () => { expect(lexicalEditor.getEditorState().read(() => $firstMention().isInline())).toBe(true); }); }); + +describe("composer skill serialization", () => { + it("reinterprets astral aliases on a provider switch without changing source text", async () => { + const prompt = "Use 𑿝review and €unknown and $review "; + await renderPrompt(prompt, true); + const readTypes = () => + lexicalEditor.getEditorState().read(() => { + const paragraph = $getRoot().getFirstChildOrThrow(); + if (!$isElementNode(paragraph)) throw new Error("Expected paragraph"); + return paragraph.getChildren().map((node) => node.getType()); + }); + expect(readTypes().filter((type) => type === "composer-skill")).toHaveLength(2); + expect(editorRef.current?.readSnapshot().value).toBe(prompt); + expect(editorRef.current?.readSnapshot().expandedCursor).toBe(prompt.length); + + await renderPrompt(prompt, false); + expect(readTypes().filter((type) => type === "composer-skill")).toHaveLength(1); + expect(editorRef.current?.readSnapshot().value).toBe(prompt); + expect(editorRef.current?.readSnapshot().expandedCursor).toBe(prompt.length); + + await renderPrompt(prompt, true); + expect(readTypes().filter((type) => type === "composer-skill")).toHaveLength(2); + expect(editorRef.current?.readSnapshot().value).toBe(prompt); + }); + + it("removes a Unicode chip when its catalog skill is disabled", async () => { + const prompt = "Use €review and $review "; + await renderPrompt(prompt, true, true); + const countChips = () => + lexicalEditor.getEditorState().read(() => { + const paragraph = $getRoot().getFirstChildOrThrow(); + if (!$isElementNode(paragraph)) throw new Error("Expected paragraph"); + return paragraph.getChildren().filter((node) => node.getType() === "composer-skill").length; + }); + expect(countChips()).toBe(2); + await renderPrompt(prompt, true, false); + expect(countChips()).toBe(1); + expect(editorRef.current?.readSnapshot().value).toBe(prompt); + }); + + it.each(["€review", "𑿝review"])("preserves %s across clone and JSON reload", async (prompt) => { + await renderPrompt(`${prompt} `); + expect(editorRef.current?.readSnapshot().value).toBe(`${prompt} `); + await act(() => { + lexicalEditor.update( + () => { + const skill = $firstSkill(); + skill.replace($copyNode(skill)); + }, + { discrete: true }, + ); + }); + const exportedState = lexicalEditor.getEditorState().toJSON(); + await renderPrompt(""); + await act(() => { + lexicalEditor.setEditorState(lexicalEditor.parseEditorState(exportedState)); + }); + expect(editorRef.current?.readSnapshot().value).toBe(`${prompt} `); + }); + + it("reads legacy skill JSON without a source as a dollar alias", async () => { + await renderPrompt(""); + await act(() => { + lexicalEditor.setEditorState( + lexicalEditor.parseEditorState( + JSON.stringify({ + root: { + type: "root", + version: 1, + children: [ + { + type: "paragraph", + version: 1, + children: [{ type: "composer-skill", version: 1, skillName: "review" }], + }, + ], + }, + }), + ), + ); + }); + expect(editorRef.current?.readSnapshot().value).toBe("$review"); + }); +}); diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index a899823bf0..f0ad62d5d4 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -71,10 +71,10 @@ import { } from "react"; import { - clampCollapsedComposerCursor, - collapseExpandedComposerCursor, - expandCollapsedComposerCursor, - isCollapsedCursorAdjacentToInlineToken, + clampCollapsedComposerCursor as clampCollapsedComposerCursorRaw, + collapseExpandedComposerCursor as collapseExpandedComposerCursorRaw, + expandCollapsedComposerCursor as expandCollapsedComposerCursorRaw, + isCollapsedCursorAdjacentToInlineToken as isCollapsedCursorAdjacentToInlineTokenRaw, } from "~/composer-logic"; import { selectionTouchesMentionBoundary, @@ -146,6 +146,7 @@ type SerializedComposerSkillNode = Spread< skillName: string; skillLabel?: string; skillDescription?: string; + source?: string; type: "composer-skill"; version: 1; }, @@ -315,6 +316,7 @@ class ComposerSkillNode extends DecoratorNode { __skillName: string; __skillLabel: string; __skillDescription: string | null; + __skillSource: string; static override getType(): string { return "composer-skill"; @@ -325,6 +327,7 @@ class ComposerSkillNode extends DecoratorNode { node.__skillName, node.__skillLabel, node.__skillDescription, + node.__skillSource, node.__key, ); } @@ -334,6 +337,7 @@ class ComposerSkillNode extends DecoratorNode { serializedNode.skillName, serializedNode.skillLabel ?? serializedNode.skillName, serializedNode.skillDescription ?? null, + serializedNode.source, ).updateFromJSON(serializedNode); } @@ -341,6 +345,7 @@ class ComposerSkillNode extends DecoratorNode { skillName: string, skillLabel: string, skillDescription: string | null, + source?: string, key?: NodeKey, ) { super(key); @@ -348,6 +353,7 @@ class ComposerSkillNode extends DecoratorNode { this.__skillName = normalizedSkillName; this.__skillLabel = skillLabel; this.__skillDescription = skillDescription; + this.__skillSource = source ?? `$${normalizedSkillName}`; } override exportJSON(): SerializedComposerSkillNode { @@ -355,6 +361,7 @@ class ComposerSkillNode extends DecoratorNode { ...super.exportJSON(), skillName: this.__skillName, skillLabel: this.__skillLabel, + source: this.__skillSource, ...(this.__skillDescription ? { skillDescription: this.__skillDescription } : {}), type: "composer-skill", version: 1, @@ -372,7 +379,7 @@ class ComposerSkillNode extends DecoratorNode { } override getTextContent(): string { - return `$${this.__skillName}`; + return this.__skillSource; } override isInline(): true { @@ -394,8 +401,11 @@ function $createComposerSkillNode( skillName: string, skillLabel: string, skillDescription: string | null, + source?: string, ): ComposerSkillNode { - return $applyNodeReplacement(new ComposerSkillNode(skillName, skillLabel, skillDescription)); + return $applyNodeReplacement( + new ComposerSkillNode(skillName, skillLabel, skillDescription, source), + ); } type ComposerInlineTokenNode = @@ -428,6 +438,7 @@ function skillSignature(skills: ReadonlyArray): string { skill.path, skill.scope ?? "", skill.enabled ? "1" : "0", + skill.userInvocable === false ? "0" : "1", ].join("\u001f"), ) .join("\u001e"); @@ -779,13 +790,19 @@ function $appendTextWithLineBreaks(parent: ElementNode, text: string): void { function $setComposerEditorPrompt( prompt: string, skillMetadata: ReadonlyMap, + allowUnicodeSkillAliases = true, + unicodeSkillNames: ReadonlySet = new Set(skillMetadata.keys()), ): void { const root = $getRoot(); root.clear(); const paragraph = $createParagraphNode(); root.append(paragraph); - const segments = splitPromptIntoComposerSegments(prompt); + const segments = splitPromptIntoComposerSegments( + prompt, + allowUnicodeSkillAliases, + unicodeSkillNames, + ); for (const segment of segments) { if (segment.type === "citation") { paragraph.append($createComposerCitationNode(segment.citation, segment.source)); @@ -802,6 +819,7 @@ function $setComposerEditorPrompt( segment.name, metadata?.label ?? formatProviderSkillDisplayName({ name: segment.name }), metadata?.description ?? null, + segment.source, ), ); continue; @@ -870,6 +888,7 @@ interface ComposerPromptEditorProps { | ((fragment: ComposerContextClipboardFragment) => ReadonlyMap) | undefined; skills: ReadonlyArray; + allowUnicodeSkillAliases?: boolean; disabled: boolean; placeholder: string; containerClassName?: string; @@ -1023,7 +1042,10 @@ function ComposerCommandKeyPlugin(props: { return null; } -function ComposerInlineTokenArrowPlugin() { +function ComposerInlineTokenArrowPlugin(props: { + allowUnicodeSkillAliases: boolean; + unicodeSkillNames: ReadonlySet; +}) { const [editor] = useLexicalComposerContext(); useEffect(() => { @@ -1037,7 +1059,15 @@ function ComposerInlineTokenArrowPlugin() { const currentOffset = $readSelectionOffsetFromEditorState(0); if (currentOffset <= 0) return; const promptValue = $getRoot().getTextContent(); - if (!isCollapsedCursorAdjacentToInlineToken(promptValue, currentOffset, "left")) { + if ( + !isCollapsedCursorAdjacentToInlineTokenRaw( + promptValue, + currentOffset, + "left", + props.allowUnicodeSkillAliases, + props.unicodeSkillNames, + ) + ) { return; } nextOffset = currentOffset - 1; @@ -1064,7 +1094,15 @@ function ComposerInlineTokenArrowPlugin() { const composerLength = $getComposerRootLength(); if (currentOffset >= composerLength) return; const promptValue = $getRoot().getTextContent(); - if (!isCollapsedCursorAdjacentToInlineToken(promptValue, currentOffset, "right")) { + if ( + !isCollapsedCursorAdjacentToInlineTokenRaw( + promptValue, + currentOffset, + "right", + props.allowUnicodeSkillAliases, + props.unicodeSkillNames, + ) + ) { return; } nextOffset = currentOffset + 1; @@ -1084,7 +1122,7 @@ function ComposerInlineTokenArrowPlugin() { unregisterLeft(); unregisterRight(); }; - }, [editor]); + }, [editor, props.allowUnicodeSkillAliases, props.unicodeSkillNames]); return null; } @@ -1366,7 +1404,11 @@ function ComposerContextClipboardPlugin(props: { return null; } -function ComposerSurroundSelectionPlugin(props: { skills: ReadonlyArray }) { +function ComposerSurroundSelectionPlugin(props: { + skills: ReadonlyArray; + allowUnicodeSkillAliases: boolean; + unicodeSkillNames: ReadonlySet; +}) { const [editor] = useLexicalComposerContext(); const skillMetadataRef = useRef(skillMetadataByName(props.skills)); const pendingSurroundSelectionRef = useRef<{ @@ -1428,10 +1470,17 @@ function ComposerSurroundSelectionPlugin(props: { skills: ReadonlyArray + new Set( + skills + .filter((skill) => skill.enabled && skill.userInvocable !== false) + .map((skill) => skill.name), + ), + [skills], + ); + const clampCollapsedComposerCursor = (text: string, cursor: number) => + clampCollapsedComposerCursorRaw(text, cursor, allowUnicodeSkillAliases, unicodeSkillNames); + const collapseExpandedComposerCursor = (text: string, cursor: number) => + collapseExpandedComposerCursorRaw(text, cursor, allowUnicodeSkillAliases, unicodeSkillNames); + const expandCollapsedComposerCursor = (text: string, cursor: number) => + expandCollapsedComposerCursorRaw(text, cursor, allowUnicodeSkillAliases, unicodeSkillNames); + const isCollapsedCursorAdjacentToInlineToken = ( + text: string, + cursor: number, + direction: "left" | "right", + ) => + isCollapsedCursorAdjacentToInlineTokenRaw( + text, + cursor, + direction, + allowUnicodeSkillAliases, + unicodeSkillNames, + ); const [editor] = useLexicalComposerContext(); const onChangeRef = useRef(onChange); const onVisibleSelectionChangeRef = useRef(onVisibleSelectionChange); const initialCursor = clampCollapsedComposerCursor(value, cursor); const initialExpandedCursor = expandCollapsedComposerCursor(value, initialCursor); - const skillsSignature = skillSignature(skills); const skillsSignatureRef = useRef(skillsSignature); + const allowUnicodeSkillAliasesRef = useRef(allowUnicodeSkillAliases); const skillMetadataRef = useRef(skillMetadataByName(skills)); const snapshotRef = useRef({ value, @@ -1720,10 +1800,12 @@ function ComposerPromptEditorInner({ const normalizedCursor = clampCollapsedComposerCursor(value, cursor); const previousSnapshot = snapshotRef.current; const skillsChanged = skillsSignatureRef.current !== skillsSignature; + const aliasPolicyChanged = allowUnicodeSkillAliasesRef.current !== allowUnicodeSkillAliases; if ( previousSnapshot.value === value && previousSnapshot.cursor === normalizedCursor && - !skillsChanged + !skillsChanged && + !aliasPolicyChanged ) { return; } @@ -1740,10 +1822,11 @@ function ComposerPromptEditorInner({ end: normalizedExpandedCursor, }; skillsSignatureRef.current = skillsSignature; + allowUnicodeSkillAliasesRef.current = allowUnicodeSkillAliases; const rootElement = editor.getRootElement(); const isFocused = Boolean(rootElement && document.activeElement === rootElement); - if (previousSnapshot.value === value && !skillsChanged && !isFocused) { + if (previousSnapshot.value === value && !skillsChanged && !aliasPolicyChanged && !isFocused) { return; } @@ -1752,9 +1835,15 @@ function ComposerPromptEditorInner({ let citationToOpen: ComposerCitationCommentTarget | null = null; editor.update( () => { - const shouldRewriteEditorState = previousSnapshot.value !== value || skillsChanged; + const shouldRewriteEditorState = + previousSnapshot.value !== value || skillsChanged || aliasPolicyChanged; if (shouldRewriteEditorState) { - $setComposerEditorPrompt(value, skillMetadataRef.current); + $setComposerEditorPrompt( + value, + skillMetadataRef.current, + allowUnicodeSkillAliases, + unicodeSkillNames, + ); } if (shouldRewriteEditorState || isFocused) { $setSelectionAtComposerOffset(normalizedCursor); @@ -1771,7 +1860,7 @@ function ComposerPromptEditorInner({ queueMicrotask(() => { isApplyingControlledUpdateRef.current = false; }); - }, [cursor, editor, skillsSignature, value]); + }, [allowUnicodeSkillAliases, cursor, editor, skillsSignature, unicodeSkillNames, value]); const focusAt = useCallback( (nextCursor: number) => { @@ -2052,9 +2141,16 @@ function ComposerPromptEditorInner({ /> - + - + @@ -2076,6 +2172,7 @@ export function ComposerPromptEditor({ buildContextClipboardFragment, importContextFragment, skills, + allowUnicodeSkillAliases = false, disabled, placeholder, containerClassName, @@ -2104,7 +2201,16 @@ export function ComposerPromptEditor({ ComposerContextReferenceNode, ], editorState: () => { - $setComposerEditorPrompt(initialValueRef.current, initialSkillMetadataRef.current); + $setComposerEditorPrompt( + initialValueRef.current, + initialSkillMetadataRef.current, + allowUnicodeSkillAliases, + new Set( + skills + .filter((skill) => skill.enabled && skill.userInvocable !== false) + .map((skill) => skill.name), + ), + ); }, onError: (error) => { throw error; @@ -2123,6 +2229,7 @@ export function ComposerPromptEditor({ buildContextClipboardFragment={buildContextClipboardFragment} importContextFragment={importContextFragment} skills={skills} + allowUnicodeSkillAliases={allowUnicodeSkillAliases} disabled={disabled} placeholder={placeholder} {...(containerClassName ? { containerClassName } : {})} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 23870cc417..c505dab3e9 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -118,17 +118,13 @@ import { } from "react"; import { createPortal, flushSync } from "react-dom"; import { - clampCollapsedComposerCursor, type ComposerSubmissionIntent, type ComposerTrigger, - collapseExpandedComposerCursor, - composerStateAtPromptEnd, composerSubmissionIntentForEnter, - detectComposerTrigger, - expandCollapsedComposerCursor, formatAssistantCitationForComposer, replaceTextRange, } from "../../composer-logic"; +import { useComposerAliasPolicyReaders } from "../../useComposerAliasPolicyReaders"; import { DISCONNECTED_COMPOSER_PLACEHOLDER } from "../../composerPlaceholder"; import { buildRunningThreadTurnInterruptInput, @@ -1094,6 +1090,7 @@ import { getProviderSkillsForSlashMenu, resolveProviderSkillsForCwd, resolveProviderSlashCommandsForCwd, + supportsUnicodeSkillAliases, } from "@t3tools/client-runtime/providerSkills"; import { searchProviderSkills } from "../../providerSkillSearch"; import { useMediaQuery } from "../../hooks/useMediaQuery"; @@ -2759,6 +2756,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const selectedProviderSkills = selectedProviderStatus ? resolveProviderSkillsForCwd(selectedProviderStatus, gitCwd) : []; + const unicodeSkillNames = new Set( + selectedProviderSkills + .filter((skill) => skill.enabled && skill.userInvocable !== false) + .map((skill) => skill.name), + ); + const allowUnicodeSkillAliases = supportsUnicodeSkillAliases(selectedProvider); + const { + clampCollapsedComposerCursor, + collapseExpandedComposerCursor, + expandCollapsedComposerCursor, + detectComposerTrigger, + composerStateAtPromptEnd, + } = useComposerAliasPolicyReaders({ allowUnicodeSkillAliases, unicodeSkillNames }); const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { reportFailure: false, }); @@ -8041,6 +8051,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) buildContextClipboardFragment={buildContextClipboardFragment} importContextFragment={importContextFragment} skills={selectedProviderSkills} + allowUnicodeSkillAliases={allowUnicodeSkillAliases} containerClassName={cn(isComposerResting && "min-w-0 flex-1")} className={cn( showMobilePendingAnswerActions && "max-sm:pb-11", diff --git a/apps/web/src/components/chat/SkillInlineText.test.tsx b/apps/web/src/components/chat/SkillInlineText.test.tsx new file mode 100644 index 0000000000..d220f17dc6 --- /dev/null +++ b/apps/web/src/components/chat/SkillInlineText.test.tsx @@ -0,0 +1,29 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import { SkillInlineText } from "./SkillInlineText"; + +describe("SkillInlineText", () => { + it("copies the original currency alias, including an astral prefix", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('data-markdown-copy="€review"'); + expect(html).toContain('data-markdown-copy="𑿝review"'); + expect(html).not.toContain('data-markdown-copy="€20"'); + expect(html).toContain("pay €20"); + }); + + it("keeps unsupported aliases literal while retaining dollar skills", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("Use €review and "); + expect(html).not.toContain('data-markdown-copy="€review"'); + expect(html).toContain('data-markdown-copy="$review"'); + }); +}); diff --git a/apps/web/src/components/chat/SkillInlineText.tsx b/apps/web/src/components/chat/SkillInlineText.tsx index 160204ee09..7b9a0e92b2 100644 --- a/apps/web/src/components/chat/SkillInlineText.tsx +++ b/apps/web/src/components/chat/SkillInlineText.tsx @@ -12,11 +12,15 @@ import { import { cn } from "~/lib/utils"; 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; + /(^|\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; type InlineSkill = Pick; -export function SkillInlineText(props: { text: string; skills: ReadonlyArray }) { +export function SkillInlineText(props: { + text: string; + skills: ReadonlyArray; + allowUnicodeSkillAliases?: boolean; +}) { const nodes: ReactNode[] = []; let cursor = 0; @@ -24,7 +28,8 @@ export function SkillInlineText(props: { text: string; skills: ReadonlyArray candidate.name === name); if (!skill) { continue; @@ -34,7 +39,7 @@ export function SkillInlineText(props: { text: string; skills: ReadonlyArray); - cursor = start + rawText.length; + cursor = (match.index ?? 0) + match[0].length; } if (cursor === 0) { @@ -49,10 +54,17 @@ export function SkillInlineText(props: { text: string; skills: ReadonlyArray, + allowUnicodeSkillAliases = false, ): ReactNode { return Children.map(children, (child) => { if (typeof child === "string") { - return ; + return ( + + ); } if (!isValidElement<{ children?: ReactNode; node?: { tagName?: string } }>(child)) { return child; @@ -69,7 +81,7 @@ export function renderSkillInlineMarkdownChildren( return cloneElement( child, undefined, - renderSkillInlineMarkdownChildren(child.props.children, skills), + renderSkillInlineMarkdownChildren(child.props.children, skills, allowUnicodeSkillAliases), ); }); } diff --git a/apps/web/src/composer-editor-mentions.test.ts b/apps/web/src/composer-editor-mentions.test.ts index 7479a8439b..0ce166a29e 100644 --- a/apps/web/src/composer-editor-mentions.test.ts +++ b/apps/web/src/composer-editor-mentions.test.ts @@ -154,7 +154,7 @@ describe("splitPromptIntoComposerSegments", () => { { type: "text", text: " " }, { type: "citation", citation, source }, { type: "text", text: "\n" }, - { type: "skill", name: "review" }, + { type: "skill", name: "review", source: "$review" }, { type: "text", text: " " }, { type: "context-reference", @@ -217,7 +217,7 @@ describe("splitPromptIntoComposerSegments", () => { it("splits skill tokens followed by whitespace into skill segments", () => { expect(splitPromptIntoComposerSegments("Use $review-follow-up please")).toEqual([ { type: "text", text: "Use " }, - { type: "skill", name: "review-follow-up" }, + { type: "skill", name: "review-follow-up", source: "$review-follow-up" }, { type: "text", text: " please" }, ]); }); @@ -225,7 +225,7 @@ 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: "skill", name: "2spec", source: "$2spec" }, { type: "text", text: " please" }, ]); }); @@ -278,7 +278,7 @@ describe("splitPromptIntoComposerSegments", () => { { type: "text", text: "Inspect " }, terminalSegment, { type: "text", text: " " }, - { type: "skill", name: "review-follow-up" }, + { type: "skill", name: "review-follow-up", source: "$review-follow-up" }, { type: "text", text: " after " }, { type: "mention", path: "AGENTS.md", source: "@AGENTS.md" }, { type: "text", text: " " }, diff --git a/apps/web/src/composer-editor-mentions.ts b/apps/web/src/composer-editor-mentions.ts index 879fcdc508..d7506d4bde 100644 --- a/apps/web/src/composer-editor-mentions.ts +++ b/apps/web/src/composer-editor-mentions.ts @@ -19,6 +19,7 @@ export type ComposerPromptSegment = | { type: "skill"; name: string; + source: string; } | { type: "citation"; @@ -74,8 +75,15 @@ function forEachMentionMatch( }); } -export function collectComposerPromptInlineTokens(text: string) { - const tokens = collectComposerInlineTokens(text); +export function collectComposerPromptInlineTokens( + text: string, + allowUnicodeSkillAliases = true, + unicodeSkillNames?: ReadonlySet, +) { + const tokens = collectComposerInlineTokens(text, { + allowUnicodeSkillAliases, + ...(unicodeSkillNames ? { unicodeSkillNames } : {}), + }); const citations = collectAssistantCitations(text); const references = collectComposerContextReferences(text); if (citations.length === 0 && references.length === 0) return tokens; @@ -93,13 +101,21 @@ export function collectComposerPromptInlineTokens(text: string) { ].sort((left, right) => left.start - right.start); } -function splitPromptTextIntoComposerSegments(text: string): ComposerPromptSegment[] { +function splitPromptTextIntoComposerSegments( + text: string, + allowUnicodeSkillAliases = true, + unicodeSkillNames?: ReadonlySet, +): ComposerPromptSegment[] { const segments: ComposerPromptSegment[] = []; if (!text) { return segments; } - const tokenMatches = collectComposerPromptInlineTokens(text); + const tokenMatches = collectComposerPromptInlineTokens( + text, + allowUnicodeSkillAliases, + unicodeSkillNames, + ); let cursor = 0; for (const match of tokenMatches) { if (match.start < cursor) { @@ -127,7 +143,7 @@ function splitPromptTextIntoComposerSegments(text: string): ComposerPromptSegmen source: match.source, }); } else { - segments.push({ type: "skill", name: match.value }); + segments.push({ type: "skill", name: match.value, source: match.source }); } cursor = match.end; @@ -174,6 +190,10 @@ export function selectionTouchesMentionBoundary( }); } -export function splitPromptIntoComposerSegments(prompt: string): ComposerPromptSegment[] { - return splitPromptTextIntoComposerSegments(prompt); +export function splitPromptIntoComposerSegments( + prompt: string, + allowUnicodeSkillAliases = true, + unicodeSkillNames?: ReadonlySet, +): ComposerPromptSegment[] { + return splitPromptTextIntoComposerSegments(prompt, allowUnicodeSkillAliases, unicodeSkillNames); } diff --git a/apps/web/src/composer-logic.test.ts b/apps/web/src/composer-logic.test.ts index 4a6113713b..007b418f53 100644 --- a/apps/web/src/composer-logic.test.ts +++ b/apps/web/src/composer-logic.test.ts @@ -187,17 +187,20 @@ describe("detectComposerTrigger", () => { }); }); - it("detects $skill trigger at cursor", () => { - const text = "Use $gh-fi"; - const trigger = detectComposerTrigger(text, text.length); - - expect(trigger).toEqual({ - kind: "skill", - query: "gh-fi", - rangeStart: "Use ".length, - rangeEnd: text.length, - }); - }); + it.each(["$", "€", "£", "¥", "₹", "₩", "₿", "𑿝"])( + "detects %sskill trigger at cursor", + (prefix) => { + const text = `Use ${prefix}gh-fi`; + const trigger = detectComposerTrigger(text, text.length); + + expect(trigger).toEqual({ + kind: "skill", + query: "gh-fi", + rangeStart: "Use ".length, + rangeEnd: text.length, + }); + }, + ); it("detects a pull request number at a token boundary", () => { const text = "Compare this with #8737"; @@ -425,10 +428,10 @@ describe("expandCollapsedComposerCursor", () => { expect(detectComposerTrigger(text, expandedCursor)).toBeNull(); }); - it("maps collapsed skill cursor to expanded text cursor", () => { - const text = "run $review-follow-up then"; + it.each(["$", "€", "𑿝"])("maps collapsed %s skill cursor to expanded text cursor", (prefix) => { + const text = `run ${prefix}review-follow-up then`; const collapsedCursorAfterSkill = "run ".length + 2; - const expandedCursorAfterSkill = "run $review-follow-up ".length; + const expandedCursorAfterSkill = `run ${prefix}review-follow-up `.length; expect(expandCollapsedComposerCursor(text, collapsedCursorAfterSkill)).toBe( expandedCursorAfterSkill, @@ -517,10 +520,10 @@ describe("collapseExpandedComposerCursor", () => { expect(expandCollapsedComposerCursor(text, collapsedCursor)).toBe(expandedCursor); }); - it("maps expanded skill cursor back to collapsed cursor", () => { - const text = "run $review-follow-up then"; + it.each(["$", "€", "𑿝"])("maps expanded %s skill cursor back to collapsed cursor", (prefix) => { + const text = `run ${prefix}review-follow-up then`; const collapsedCursorAfterSkill = "run ".length + 2; - const expandedCursorAfterSkill = "run $review-follow-up ".length; + const expandedCursorAfterSkill = `run ${prefix}review-follow-up `.length; expect(collapseExpandedComposerCursor(text, expandedCursorAfterSkill)).toBe( collapsedCursorAfterSkill, @@ -528,6 +531,13 @@ describe("collapseExpandedComposerCursor", () => { }); }); +it("preserves the caret before trailing text after mixed-width skill chips", () => { + const text = "𑿝ui a $review tail"; + const expanded = "𑿝ui a $review ".length; + expect(collapseExpandedComposerCursor(text, expanded)).toBe(6); + expect(expandCollapsedComposerCursor(text, 6)).toBe(expanded); +}); + describe("clampCollapsedComposerCursor", () => { it("clamps to collapsed prompt length when mentions are present", () => { const text = "open @AGENTS.md then "; diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index 57643ba0ea..87d443ecdb 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -54,9 +54,18 @@ function tokenStartForCursor(text: string, cursor: number): number { return index + 1; } -export function expandCollapsedComposerCursor(text: string, cursorInput: number): number { +export function expandCollapsedComposerCursor( + text: string, + cursorInput: number, + allowUnicodeSkillAliases = true, + unicodeSkillNames?: ReadonlySet, +): number { const collapsedCursor = clampCursor(text, cursorInput); - const segments = splitPromptIntoComposerSegments(text); + const segments = splitPromptIntoComposerSegments( + text, + allowUnicodeSkillAliases, + unicodeSkillNames, + ); if (segments.length === 0) { return collapsedCursor; } @@ -79,7 +88,7 @@ export function expandCollapsedComposerCursor(text: string, cursorInput: number) continue; } if (segment.type === "skill") { - const expandedLength = segment.name.length + 1; + const expandedLength = segment.source.length; if (remaining <= 1) { return expandedCursor + (remaining === 0 ? 0 : expandedLength); } @@ -120,16 +129,30 @@ function clampCollapsedComposerCursorForSegments( return Math.max(0, Math.min(collapsedLength, Math.floor(cursorInput))); } -export function clampCollapsedComposerCursor(text: string, cursorInput: number): number { +export function clampCollapsedComposerCursor( + text: string, + cursorInput: number, + allowUnicodeSkillAliases = true, + unicodeSkillNames?: ReadonlySet, +): number { return clampCollapsedComposerCursorForSegments( - splitPromptIntoComposerSegments(text), + splitPromptIntoComposerSegments(text, allowUnicodeSkillAliases, unicodeSkillNames), cursorInput, ); } -export function collapseExpandedComposerCursor(text: string, cursorInput: number): number { +export function collapseExpandedComposerCursor( + text: string, + cursorInput: number, + allowUnicodeSkillAliases = true, + unicodeSkillNames?: ReadonlySet, +): number { const expandedCursor = clampCursor(text, cursorInput); - const segments = splitPromptIntoComposerSegments(text); + const segments = splitPromptIntoComposerSegments( + text, + allowUnicodeSkillAliases, + unicodeSkillNames, + ); if (segments.length === 0) { return expandedCursor; } @@ -155,7 +178,7 @@ export function collapseExpandedComposerCursor(text: string, cursorInput: number continue; } if (segment.type === "skill") { - const expandedLength = segment.name.length + 1; + const expandedLength = segment.source.length; if (remaining === 0) { return collapsedCursor; } @@ -182,8 +205,14 @@ export function isCollapsedCursorAdjacentToInlineToken( text: string, cursorInput: number, direction: "left" | "right", + allowUnicodeSkillAliases = true, + unicodeSkillNames?: ReadonlySet, ): boolean { - const segments = splitPromptIntoComposerSegments(text); + const segments = splitPromptIntoComposerSegments( + text, + allowUnicodeSkillAliases, + unicodeSkillNames, + ); if (!segments.some(isInlineTokenSegment)) { return false; } @@ -206,7 +235,11 @@ export function isCollapsedCursorAdjacentToInlineToken( return false; } -export function detectComposerTrigger(text: string, cursorInput: number): ComposerTrigger | null { +export function detectComposerTrigger( + text: string, + cursorInput: number, + allowUnicodeSkillAliases = true, +): ComposerTrigger | null { const cursor = clampCursor(text, cursorInput); const lineStart = text.lastIndexOf("\n", Math.max(0, cursor - 1)) + 1; const linePrefix = text.slice(lineStart, cursor); @@ -235,10 +268,11 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos rangeEnd: cursor, }; } - if (token.startsWith("$")) { + const skillPrefix = /^\p{Sc}/u.exec(token); + if (skillPrefix && (allowUnicodeSkillAliases || skillPrefix[0] === "$")) { return { kind: "skill", - query: token.slice(1), + query: token.slice(skillPrefix[0].length), rangeStart: tokenStart, rangeEnd: cursor, }; @@ -256,14 +290,75 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos } /** Caret and trigger after replacing composer text and continuing at the end. */ -export function composerStateAtPromptEnd(text: string): { +export function composerStateAtPromptEnd( + text: string, + allowUnicodeSkillAliases = true, + unicodeSkillNames?: ReadonlySet, +): { cursor: number; trigger: ComposerTrigger | null; } { - const cursor = collapseExpandedComposerCursor(text, text.length); + const cursor = collapseExpandedComposerCursor( + text, + text.length, + allowUnicodeSkillAliases, + unicodeSkillNames, + ); return { cursor, - trigger: detectComposerTrigger(text, expandCollapsedComposerCursor(text, cursor)), + trigger: detectComposerTrigger( + text, + expandCollapsedComposerCursor(text, cursor, allowUnicodeSkillAliases, unicodeSkillNames), + allowUnicodeSkillAliases, + ), + }; +} + +export interface ComposerAliasPolicy { + readonly allowUnicodeSkillAliases: boolean; + readonly unicodeSkillNames: ReadonlySet; +} + +/** Stable readers for callbacks that outlive a provider or skill-catalog selection. */ +export function createComposerAliasPolicyReaders(getPolicy: () => ComposerAliasPolicy) { + return { + clampCollapsedComposerCursor: (text: string, cursor: number) => { + const policy = getPolicy(); + return clampCollapsedComposerCursor( + text, + cursor, + policy.allowUnicodeSkillAliases, + policy.unicodeSkillNames, + ); + }, + collapseExpandedComposerCursor: (text: string, cursor: number) => { + const policy = getPolicy(); + return collapseExpandedComposerCursor( + text, + cursor, + policy.allowUnicodeSkillAliases, + policy.unicodeSkillNames, + ); + }, + expandCollapsedComposerCursor: (text: string, cursor: number) => { + const policy = getPolicy(); + return expandCollapsedComposerCursor( + text, + cursor, + policy.allowUnicodeSkillAliases, + policy.unicodeSkillNames, + ); + }, + detectComposerTrigger: (text: string, cursor: number) => + detectComposerTrigger(text, cursor, getPolicy().allowUnicodeSkillAliases), + composerStateAtPromptEnd: (text: string) => { + const policy = getPolicy(); + return composerStateAtPromptEnd( + text, + policy.allowUnicodeSkillAliases, + policy.unicodeSkillNames, + ); + }, }; } diff --git a/apps/web/src/providerSkillSearch.test.ts b/apps/web/src/providerSkillSearch.test.ts index 01059d6148..b02cd2805c 100644 --- a/apps/web/src/providerSkillSearch.test.ts +++ b/apps/web/src/providerSkillSearch.test.ts @@ -13,6 +13,14 @@ function makeSkill(input: Partial & Pick { + it.each(["$", "€", "£", "¥", "₹", "₩", "₿", "𑿝"])( + "searches skills with a %s prefix", + (prefix) => { + const skill = makeSkill({ name: "review" }); + expect(searchProviderSkills([skill], `${prefix}review`)).toEqual([skill]); + }, + ); + it("moves exact ui matches ahead of broader ui matches", () => { const skills = [ makeSkill({ diff --git a/apps/web/src/providerSkillSearch.ts b/apps/web/src/providerSkillSearch.ts index 21dd287448..a570c92d91 100644 --- a/apps/web/src/providerSkillSearch.ts +++ b/apps/web/src/providerSkillSearch.ts @@ -75,7 +75,7 @@ export function searchProviderSkills( limit = Number.POSITIVE_INFINITY, ): ServerProviderSkill[] { const enabledSkills = dedupeProviderSkillsByName(skills.filter(isProviderSkillUserInvocable)); - const normalizedQuery = normalizeSearchQuery(query, { trimLeadingPattern: /^\$+/ }); + const normalizedQuery = normalizeSearchQuery(query, { trimLeadingPattern: /^\p{Sc}+/u }); if (!normalizedQuery) { return enabledSkills; diff --git a/apps/web/src/useComposerAliasPolicyReaders.test.tsx b/apps/web/src/useComposerAliasPolicyReaders.test.tsx new file mode 100644 index 0000000000..d59270bd75 --- /dev/null +++ b/apps/web/src/useComposerAliasPolicyReaders.test.tsx @@ -0,0 +1,82 @@ +import { Suspense, act, createElement } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import type { ComposerAliasPolicy } from "./composer-logic"; +import { useComposerAliasPolicyReaders } from "./useComposerAliasPolicyReaders"; + +let renderer: ReactTestRenderer | undefined; +let readers: ReturnType; + +const pendingRender = new Promise(() => {}); + +function Harness(props: { policy: ComposerAliasPolicy; suspend?: boolean }) { + readers = useComposerAliasPolicyReaders(props.policy); + if (props.suspend) throw pendingRender; + return null; +} + +beforeEach(() => vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true)); +afterEach(async () => { + await act(() => renderer?.unmount()); + renderer = undefined; + vi.unstubAllGlobals(); +}); + +describe("useComposerAliasPolicyReaders", () => { + it("does not expose a provider policy from a suspended render", async () => { + const enabled = { allowUnicodeSkillAliases: true, unicodeSkillNames: new Set(["review"]) }; + await act(() => { + renderer = create( + createElement(Suspense, { fallback: null }, createElement(Harness, { policy: enabled })), + ); + }); + const capturedTrigger = readers.detectComposerTrigger; + expect(capturedTrigger("€review", "€review".length)?.kind).toBe("skill"); + + await act(() => { + renderer?.update( + createElement( + Suspense, + { fallback: null }, + createElement(Harness, { + policy: { allowUnicodeSkillAliases: false, unicodeSkillNames: new Set(["review"]) }, + suspend: true, + }), + ), + ); + }); + expect(capturedTrigger("€review", "€review".length)?.kind).toBe("skill"); + }); + + it("updates captured composer callbacks across provider switches and catalog removal", async () => { + const prompt = "Use 𑿝review and $review "; + const render = async (policy: ComposerAliasPolicy) => { + await act(() => { + if (renderer) renderer.update(createElement(Harness, { policy })); + else renderer = create(createElement(Harness, { policy })); + }); + }; + + await render({ allowUnicodeSkillAliases: true, unicodeSkillNames: new Set(["review"]) }); + const capturedCollapse = readers.collapseExpandedComposerCursor; + const capturedExpand = readers.expandCollapsedComposerCursor; + const capturedTrigger = readers.detectComposerTrigger; + const supportedCursor = capturedCollapse(prompt, prompt.length); + expect(capturedExpand(prompt, supportedCursor)).toBe(prompt.length); + expect(capturedTrigger("€review", "€review".length)?.kind).toBe("skill"); + + await render({ allowUnicodeSkillAliases: false, unicodeSkillNames: new Set(["review"]) }); + expect(readers.collapseExpandedComposerCursor).toBe(capturedCollapse); + expect(capturedCollapse(prompt, prompt.length)).toBeGreaterThan(supportedCursor); + expect(capturedExpand(prompt, capturedCollapse(prompt, prompt.length))).toBe(prompt.length); + expect(capturedTrigger("€review", "€review".length)).toBeNull(); + + await render({ allowUnicodeSkillAliases: true, unicodeSkillNames: new Set() }); + expect(capturedCollapse(prompt, prompt.length)).toBeGreaterThan(supportedCursor); + expect(capturedExpand(prompt, capturedCollapse(prompt, prompt.length))).toBe(prompt.length); + + await render({ allowUnicodeSkillAliases: true, unicodeSkillNames: new Set(["review"]) }); + expect(capturedCollapse(prompt, prompt.length)).toBe(supportedCursor); + }); +}); diff --git a/apps/web/src/useComposerAliasPolicyReaders.ts b/apps/web/src/useComposerAliasPolicyReaders.ts new file mode 100644 index 0000000000..831bd12f33 --- /dev/null +++ b/apps/web/src/useComposerAliasPolicyReaders.ts @@ -0,0 +1,15 @@ +import { useLayoutEffect, useRef, useState } from "react"; + +import { createComposerAliasPolicyReaders, type ComposerAliasPolicy } from "./composer-logic"; + +/** Keep long-lived composer handlers bound to the current provider and catalog. */ +export function useComposerAliasPolicyReaders(policy: ComposerAliasPolicy) { + const policyRef = useRef(policy); + // Commit the policy before event handlers run. An interrupted concurrent + // render must not expose a provider selection that never became visible. + useLayoutEffect(() => { + policyRef.current = policy; + }, [policy]); + const [readers] = useState(() => createComposerAliasPolicyReaders(() => policyRef.current)); + return readers; +} diff --git a/packages/client-runtime/src/providerSkills.test.ts b/packages/client-runtime/src/providerSkills.test.ts index 0263c56113..65bca40497 100644 --- a/packages/client-runtime/src/providerSkills.test.ts +++ b/packages/client-runtime/src/providerSkills.test.ts @@ -9,6 +9,7 @@ import { resolveProviderSkillsForCwd, resolveProviderSlashCommandsForCwd, resolveProviderSkillSourceKind, + supportsUnicodeSkillAliases, } from "./providerSkills.ts"; const provider = { @@ -33,6 +34,18 @@ const provider = { ], } satisfies ServerProvider; +describe("supportsUnicodeSkillAliases", () => { + it("matches only adapters that normalize registered Unicode skill invocations", () => { + for (const driver of ["claudeAgent", "cursor", "codex"] as const) { + expect(supportsUnicodeSkillAliases(ProviderDriverKind.make(driver))).toBe(true); + } + for (const driver of ["grok", "openCode", "prime"] as const) { + expect(supportsUnicodeSkillAliases(ProviderDriverKind.make(driver))).toBe(false); + } + expect(supportsUnicodeSkillAliases(null)).toBe(false); + }); +}); + describe("formatProviderSkillDisplayName", () => { it("prefers the provider display name", () => { expect( diff --git a/packages/client-runtime/src/providerSkills.ts b/packages/client-runtime/src/providerSkills.ts index b80cd58038..bba79efea4 100644 --- a/packages/client-runtime/src/providerSkills.ts +++ b/packages/client-runtime/src/providerSkills.ts @@ -1,9 +1,17 @@ import type { + ProviderDriverKind, ServerProvider, ServerProviderSkill, ServerProviderSlashCommand, } from "@t3tools/contracts"; +/** Only these adapters turn a Unicode currency alias into a native skill invocation. */ +export function supportsUnicodeSkillAliases( + driver: ProviderDriverKind | null | undefined, +): boolean { + return driver === "claudeAgent" || driver === "cursor" || driver === "codex"; +} + export type ProviderSkillSourceKind = "app" | "repo" | "project" | "personal" | "system" | "other"; function titleCaseWords(value: string): string { diff --git a/packages/effect-codex-app-server/src/protocol.test.ts b/packages/effect-codex-app-server/src/protocol.test.ts index cd6d0015fd..0fa9aa9025 100644 --- a/packages/effect-codex-app-server/src/protocol.test.ts +++ b/packages/effect-codex-app-server/src/protocol.test.ts @@ -725,6 +725,37 @@ it.layer(NodeServices.layer)("effect-codex-app-server protocol", (it) => { }), ); + it.effect( + "ignores a late skill catalog reply after cancellation and serves the next request", + () => + Effect.gen(function* () { + const { stdio, input, output } = yield* makeInMemoryStdio(); + const transport = yield* CodexProtocol.makeCodexAppServerPatchedProtocol({ stdio }); + + const catalog = yield* transport + .request("skills/list", { cwds: ["/project"] }) + .pipe(Effect.forkScoped); + assert.deepEqual(yield* decodeJson(yield* Queue.take(output)), { + id: 1, + method: "skills/list", + params: { cwds: ["/project"] }, + }); + yield* Fiber.interrupt(catalog); + yield* Queue.offer(input, encodeJsonl({ id: 1, result: { data: [] } })); + + const next = yield* transport + .request("thread/read", { threadId: "thread-2" }) + .pipe(Effect.forkScoped); + assert.deepEqual(yield* decodeJson(yield* Queue.take(output)), { + id: 2, + method: "thread/read", + params: { threadId: "thread-2" }, + }); + yield* Queue.offer(input, encodeJsonl({ id: 2, result: { threadId: "thread-2" } })); + assert.deepEqual(yield* Fiber.join(next), { threadId: "thread-2" }); + }), + ); + it.effect("logs decode failures without copying the cause or wire payload", () => Effect.gen(function* () { const secret = "codex-wire-secret-sentinel"; diff --git a/packages/shared/src/composerInlineTokens.test.ts b/packages/shared/src/composerInlineTokens.test.ts index 56243c2d84..6bbea6319e 100644 --- a/packages/shared/src/composerInlineTokens.test.ts +++ b/packages/shared/src/composerInlineTokens.test.ts @@ -3,6 +3,27 @@ import { describe, expect, it } from "vite-plus/test"; import { collectComposerInlineTokens } from "./composerInlineTokens.ts"; describe("collectComposerInlineTokens", () => { + it("chips only registered Unicode aliases while keeping dollar syntax", () => { + const tokens = collectComposerInlineTokens("Use €ui and €unknown and $unknown ", { + unicodeSkillNames: new Set(["ui"]), + }); + expect(tokens.map((token) => token.source)).toEqual(["€ui", "$unknown"]); + }); + + it("treats Unicode aliases as literal text when provider dispatch is unsupported", () => { + const text = "Use €ui and 𑿝ui and $ui "; + expect(collectComposerInlineTokens(text, { allowUnicodeSkillAliases: false })).toEqual([ + { type: "skill", value: "ui", source: "$ui", start: 21, end: 24 }, + ]); + const confirmed = collectComposerInlineTokens("Use €ui "); + expect( + collectComposerInlineTokens("Use €ui", { + allowUnicodeSkillAliases: false, + preserveTrailingFrom: confirmed, + }), + ).toEqual([]); + }); + it("collects file links, mentions, and skills with source ranges", () => { const text = "Use $ui and inspect [Chat.tsx](src/Chat.tsx) with @AGENTS.md please"; @@ -31,16 +52,27 @@ 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.each(["$", "€", "£", "¥", "₹", "₩", "₿", "𑿝"])( + "collects %s skill names that begin with a digit", + (prefix) => { + expect(collectComposerInlineTokens(`Use ${prefix}2spec next`)).toEqual([ + { + type: "skill", + value: "2spec", + source: `${prefix}2spec`, + start: 4, + end: 9 + prefix.length, + }, + ]); + }, + ); + + it.each(["€", "£", "¥", "₹", "₩", "₿", "𑿝"])("leaves %s currency amounts as text", (prefix) => { + expect( + collectComposerInlineTokens( + `${prefix}20 ${prefix}1_000 ${prefix}20k ${prefix}100M ${prefix}1e6 `, + ), + ).toEqual([]); }); it("leaves digits-only dollar amounts and compact monetary expressions as text", () => { diff --git a/packages/shared/src/composerInlineTokens.ts b/packages/shared/src/composerInlineTokens.ts index bb39dd5994..bc233cb075 100644 --- a/packages/shared/src/composerInlineTokens.ts +++ b/packages/shared/src/composerInlineTokens.ts @@ -16,6 +16,8 @@ export type ComposerInlineToken = export interface CollectComposerInlineTokensOptions { readonly preserveTrailingFrom?: ReadonlyArray; + readonly allowUnicodeSkillAliases?: boolean; + readonly unicodeSkillNames?: ReadonlySet; } /** @@ -26,7 +28,7 @@ export interface CollectComposerInlineTokensOptions { * 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; + /(^|\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; const MENTION_TOKEN_REGEX = /(^|\s)@(?:"((?:\\.|[^"\\])*)"|([^\s@"]+))(?=\s)/g; /** * The label body is bounded rather than `*`. Unbounded, every whitespace in @@ -117,6 +119,9 @@ export function collectComposerInlineTokens( } const start = (match.index ?? 0) + prefix.length; const end = start + fullMatch.length - prefix.length; + if (options.allowUnicodeSkillAliases === false && text[start] !== "$") continue; + if (text[start] !== "$" && options.unicodeSkillNames && !options.unicodeSkillNames.has(value)) + continue; matches.push({ type: "skill", value, @@ -130,6 +135,13 @@ export function collectComposerInlineTokens( if ( token.end === text.length && text.slice(token.start, token.end) === token.source && + (options.allowUnicodeSkillAliases !== false || + token.type !== "skill" || + token.source.startsWith("$")) && + (token.type !== "skill" || + token.source.startsWith("$") || + !options.unicodeSkillNames || + options.unicodeSkillNames.has(token.value)) && !matches.some( (match) => match.type === token.type && match.start === token.start && match.end === token.end, diff --git a/packages/shared/src/composerTrigger.test.ts b/packages/shared/src/composerTrigger.test.ts index 4b27638544..4f2b8150d9 100644 --- a/packages/shared/src/composerTrigger.test.ts +++ b/packages/shared/src/composerTrigger.test.ts @@ -1,6 +1,26 @@ import { describe, expect, it } from "vite-plus/test"; -import { serializeComposerFileLink } from "./composerTrigger.ts"; +import { detectComposerTrigger, serializeComposerFileLink } from "./composerTrigger.ts"; + +describe("detectComposerTrigger", () => { + it("offers dollar skills but not Unicode aliases for unsupported providers", () => { + expect(detectComposerTrigger("Use €review", 11, undefined, false)).toBeNull(); + expect(detectComposerTrigger("Use $review", 11, undefined, false)?.kind).toBe("skill"); + }); + + it.each(["$", "€", "£", "¥", "₹", "₩", "₿", "𑿝"])( + "detects %s skill prefixes and their source range", + (prefix) => { + const text = `Use ${prefix}review`; + expect(detectComposerTrigger(text, text.length)).toEqual({ + kind: "skill", + query: "review", + rangeStart: 4, + rangeEnd: text.length, + }); + }, + ); +}); describe("serializeComposerFileLink", () => { it("uses the basename as the markdown label", () => { diff --git a/packages/shared/src/composerTrigger.ts b/packages/shared/src/composerTrigger.ts index 46e56dacfc..b8806b9239 100644 --- a/packages/shared/src/composerTrigger.ts +++ b/packages/shared/src/composerTrigger.ts @@ -56,6 +56,7 @@ export function detectComposerTrigger( text: string, cursorInput: number, isWhitespaceChar?: (char: string) => boolean, + allowUnicodeSkillAliases = true, ): ComposerTrigger | null { const cursor = clampCursor(text, cursorInput); const lineStart = text.lastIndexOf("\n", Math.max(0, cursor - 1)) + 1; @@ -108,10 +109,11 @@ export function detectComposerTrigger( rangeStart: tokenStart, rangeEnd: cursor, }; - if (token.startsWith("$")) { + const skillPrefix = /^\p{Sc}/u.exec(token); + if (skillPrefix && (allowUnicodeSkillAliases || skillPrefix[0] === "$")) { return { kind: "skill", - query: token.slice(1), + query: token.slice(skillPrefix[0].length), rangeStart: tokenStart, rangeEnd: cursor, };