authorgravatar for saiansh2525@gmail.comYash Singh <saiansh2525@gmail.com> 2026-08-30 18:09:47-05:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-30 18:09:47-05:00
logc1e70b5f8c93428c1fe7af62f24ee6ff2bffff6e
tree36fa190a9548fb31531e43dcc816f6404bc131e2
parente09b88b6a51bb7e4309498f01c2f36bf5ada43f4
signature Signed by PGP key B5690EEEBB952194

fix(web,mobile): render Codex citations and artifact templates (#8584)


19 files changed, 1558 insertions(+), 32 deletions(-)

apps/mobile/src/features/threads/ThreadDetailScreen.tsx+23
......@@ -1,4 +1,8 @@
11import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
2import {
3 appendCodexArtifactTemplateUsePrompt,
4 type CodexArtifactTemplate,
5} from "@t3tools/client-runtime/codex-artifact-templates";
26import type { EnvironmentThreadStatus } from "@t3tools/client-runtime/state/threads";
37import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard";
48import type { LegendListRef } from "@legendapp/list/react-native";
......@@ -260,6 +264,8 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
260264 const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`;
261265 const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id);
262266 const composerEditorRef = useRef<ComposerEditorHandle>(null);
267 const draftMessageRef = useRef(props.draftMessage);
268 draftMessageRef.current = props.draftMessage;
263269 const composerOverlayRef = useRef<View>(null);
264270 const listRef = useRef<LegendListRef>(null);
265271 const feedTouchStartRef = useRef<{ pageX: number; pageY: number } | null>(null);
......@@ -601,6 +607,22 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
601607 composerEditorRef.current?.blur();
602608 }, []);
603609
610 const handleUseArtifactTemplate = useCallback(
611 (template: CodexArtifactTemplate) => {
612 const currentDraft = draftMessageRef.current;
613 const nextDraft = appendCodexArtifactTemplateUsePrompt(currentDraft, template);
614 if (nextDraft !== currentDraft) {
615 draftMessageRef.current = nextDraft;
616 props.onChangeDraftMessage(nextDraft);
617 }
618 requestAnimationFrame(() => {
619 composerEditorRef.current?.focus();
620 composerEditorRef.current?.setSelection({ start: nextDraft.length, end: nextDraft.length });
621 });
622 },
623 [props.onChangeDraftMessage],
624 );
625
604626 const handleScrollToEnd = useCallback(() => {
605627 void Haptics.selectionAsync();
606628 void scrollMessageToEnd({ animated: true, closeKeyboard: false }).catch(() => {
......@@ -677,6 +699,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
677699 onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange}
678700 onEndFollowEnabledChange={setEndFollowEnabled}
679701 skills={selectedProviderSkills}
702 onUseArtifactTemplate={handleUseArtifactTemplate}
680703 loadEarlier={props.loadEarlier ?? null}
681704 />
682705 </View>
apps/mobile/src/features/threads/ThreadFeed.tsx+135-24
......@@ -10,12 +10,20 @@ import type {
1010 ThreadId,
1111 TurnId,
1212} from "@t3tools/contracts";
13import {
14 codexArtifactTemplatePresentationLabel,
15 type CodexArtifactTemplate,
16} from "@t3tools/client-runtime/codex-artifact-templates";
1317import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";
1418import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments";
1519import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
1620import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images";
21import {
22 renderCodexFileCitationsAsMarkdown,
23 splitCodexArtifactTemplateMarkdown,
24} from "@t3tools/client-runtime/codex-markdown-directives";
1725import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";
18import { SymbolView } from "../../components/AppSymbol";
26import { SymbolView, type AppSymbolName } from "../../components/AppSymbol";
1927import { HeaderHeightContext } from "@react-navigation/elements";
2028import { useFocusEffect, useNavigation } from "@react-navigation/native";
2129import {
......@@ -190,6 +198,7 @@ export interface ThreadFeedProps {
190198 readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void;
191199 readonly onEndFollowEnabledChange?: (enabled: boolean) => void;
192200 readonly skills?: ReadonlyArray<SelectableMarkdownSkill>;
201 readonly onUseArtifactTemplate?: (template: CodexArtifactTemplate) => void;
193202 /** Non-null when older turns exist beyond the loaded window. */
194203 readonly loadEarlier?: {
195204 readonly loading: boolean;
......@@ -568,6 +577,115 @@ const MarkdownExternalLink = memo(function MarkdownExternalLink(props: {
568577 );
569578});
570579
580const ARTIFACT_TEMPLATE_SYMBOL_BY_KIND: Record<
581 CodexArtifactTemplate["artifactKind"],
582 AppSymbolName
583> = {
584 document: "doc.text",
585 presentation: "chart.bar.xaxis",
586 spreadsheet: "chart.bar.xaxis",
587 site: "safari",
588 "google-docs": "doc.text",
589 "google-slides": "chart.bar.xaxis",
590 "google-sheets": "chart.bar.xaxis",
591 image: "camera",
592 email: "text.bubble",
593 slack: "text.bubble",
594};
595
596function ArtifactTemplateCard(props: {
597 readonly template: CodexArtifactTemplate;
598 readonly onUse?: ((template: CodexArtifactTemplate) => void) | undefined;
599}) {
600 return (
601 <View className="my-2 min-w-0 flex-row items-center gap-3 rounded-2xl border border-border bg-card px-3 py-3">
602 <View className="relative h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-border bg-subtle">
603 <SymbolView
604 name={ARTIFACT_TEMPLATE_SYMBOL_BY_KIND[props.template.artifactKind]}
605 size={20}
606 tintColorClassName="accent-foreground-muted"
607 type="monochrome"
608 />
609 <View className="absolute -right-1 -bottom-1 h-4 w-4 items-center justify-center rounded-full bg-fuchsia-500">
610 <SymbolView
611 name={{ ios: "sparkles", android: "auto_awesome" }}
612 size={9}
613 tintColor="white"
614 type="monochrome"
615 />
616 </View>
617 </View>
618 <View className="min-w-0 flex-1">
619 <Text className="font-t3-bold text-sm text-foreground" numberOfLines={1}>
620 {props.template.displayName}
621 </Text>
622 <Text className="text-xs text-foreground-muted">
623 {codexArtifactTemplatePresentationLabel(props.template.artifactKind)}
624 </Text>
625 </View>
626 {props.onUse ? (
627 <Pressable
628 accessibilityRole="button"
629 accessibilityLabel={`Use ${props.template.displayName} template`}
630 className="min-h-9 justify-center rounded-lg border border-border bg-subtle px-3 active:opacity-65"
631 onPress={() => props.onUse?.(props.template)}
632 >
633 <Text className="font-t3-bold text-xs text-foreground">Use template</Text>
634 </Pressable>
635 ) : null}
636 </View>
637 );
638}
639
640const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: {
641 readonly markdown: string;
642 readonly markdownStyles: MarkdownStyleSet;
643 readonly onLinkPress: (href: string) => void;
644 readonly onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined;
645 readonly renderImage: MarkdownImageRenderer;
646 readonly skills?: ReadonlyArray<SelectableMarkdownSkill> | undefined;
647}) {
648 const segments = useMemo(
649 () => splitCodexArtifactTemplateMarkdown(props.markdown),
650 [props.markdown],
651 );
652
653 return segments.map((segment) => {
654 if (segment.kind === "artifact-template") {
655 return (
656 <ArtifactTemplateCard
657 key={`artifact-template:${segment.sourceOffset}`}
658 template={segment.template}
659 onUse={props.onUseArtifactTemplate}
660 />
661 );
662 }
663 if (segment.markdown.trim().length === 0) return null;
664
665 const markdown = renderCodexFileCitationsAsMarkdown(segment.markdown);
666 return hasNativeSelectableMarkdownText() ? (
667 <SelectableMarkdownText
668 key={`markdown:${segment.sourceOffset}`}
669 markdown={markdown}
670 skills={props.skills}
671 textStyle={props.markdownStyles.nativeTextStyle}
672 onLinkPress={props.onLinkPress}
673 renderImage={props.renderImage}
674 />
675 ) : (
676 <Markdown
677 key={`markdown:${segment.sourceOffset}`}
678 options={{ gfm: true }}
679 renderers={props.markdownStyles.renderers}
680 styles={props.markdownStyles.styles}
681 theme={props.markdownStyles.theme}
682 >
683 {markdown}
684 </Markdown>
685 );
686 });
687});
688
571689function MarkdownCodeBlock(props: {
572690 readonly backgroundColor: string;
573691 readonly borderColor: string;
......@@ -1099,7 +1217,7 @@ function useMarkdownStyles(
10991217
11001218function renderFeedEntry(
11011219 info: { item: ThreadFeedEntry; index: number },
1102 props: Pick<ThreadFeedProps, "environmentId" | "skills"> & {
1220 props: Pick<ThreadFeedProps, "environmentId" | "onUseArtifactTemplate" | "skills"> & {
11031221 readonly copiedRowId: string | null;
11041222 readonly expandedWorkRows: Record<string, boolean>;
11051223 readonly terminalAssistantMessageIds: ReadonlySet<string>;
......@@ -1162,6 +1280,7 @@ function renderFeedEntry(
11621280 if (entry.type === "message") {
11631281 const { message } = entry;
11641282 const isUser = message.role === "user";
1283 const renderedText = message.text;
11651284 const styles = isUser ? markdownStyles.user : markdownStyles.assistant;
11661285 const timestampLabel = formatMessageTime(isUser ? message.createdAt : message.updatedAt);
11671286 const attachments = message.attachments ?? [];
......@@ -1171,7 +1290,7 @@ function renderFeedEntry(
11711290 // children during the unclamped pass and never moves them once the width
11721291 // is clamped, so the paragraphs around the block end up drawn on top of
11731292 // each other. Pinning the width removes that pass.
1174 const hasWideBlock = hasWideMarkdownBlock(message.text, WIDE_MARKDOWN_BLOCK_OPTIONS);
1293 const hasWideBlock = hasWideMarkdownBlock(renderedText, WIDE_MARKDOWN_BLOCK_OPTIONS);
11751294 const assistantTurnStillInProgress =
11761295 message.role === "assistant" &&
11771296 props.unsettledTurnId !== null &&
......@@ -1251,7 +1370,7 @@ function renderFeedEntry(
12511370
12521371 // Skip empty assistant messages (no text, no attachments) — they would
12531372 // render as an orphaned timestamp and break adjacent activity-group merging.
1254 if (message.text.trim().length === 0 && attachments.length === 0) {
1373 if (renderedText.trim().length === 0 && attachments.length === 0) {
12551374 return null;
12561375 }
12571376
......@@ -1261,25 +1380,15 @@ function renderFeedEntry(
12611380 className={cn(showAssistantMeta ? "mb-5 px-1" : "mb-2 px-1")}
12621381 {...(enterAnimated ? { entering: FadeIn.duration(220) } : {})}
12631382 >
1264 {message.text.trim().length > 0 ? (
1265 hasNativeSelectableMarkdownText() ? (
1266 <SelectableMarkdownText
1267 markdown={message.text}
1268 skills={props.skills}
1269 textStyle={styles.nativeTextStyle}
1270 onLinkPress={props.onMarkdownLinkPress}
1271 renderImage={props.renderMarkdownImage}
1272 />
1273 ) : (
1274 <Markdown
1275 options={{ gfm: true }}
1276 renderers={styles.renderers}
1277 styles={styles.styles}
1278 theme={styles.theme}
1279 >
1280 {message.text}
1281 </Markdown>
1282 )
1383 {renderedText.trim().length > 0 ? (
1384 <AssistantMarkdownContent
1385 markdown={renderedText}
1386 markdownStyles={styles}
1387 onLinkPress={props.onMarkdownLinkPress}
1388 onUseArtifactTemplate={props.onUseArtifactTemplate}
1389 renderImage={props.renderMarkdownImage}
1390 skills={props.skills}
1391 />
12831392 ) : null}
12841393 {attachments.map((attachment) => {
12851394 return isImageAttachment(attachment) ? (
......@@ -1304,7 +1413,7 @@ function renderFeedEntry(
13041413 <View className="mt-1 flex-row items-center gap-1">
13051414 <CopyTextButton
13061415 accessibilityLabel="Copy message"
1307 text={message.text}
1416 text={renderedText}
13081417 tintColor={iconSubtleColor}
13091418 buttonSize={28}
13101419 iconSize={13}
......@@ -2192,6 +2301,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
21922301 reviewCommentBubbleWidth,
21932302 userBubbleMaxWidth,
21942303 skills: props.skills,
2304 onUseArtifactTemplate: props.onUseArtifactTemplate,
21952305 })}
21962306 </Animated.View>
21972307 ),
......@@ -2214,6 +2324,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
22142324 onToggleWorkGroup,
22152325 onToggleWorkRow,
22162326 props.environmentId,
2327 props.onUseArtifactTemplate,
22172328 props.skills,
22182329 renderMarkdownImage,
22192330 ],
apps/web/src/components/ChatMarkdown.test.tsx+208
......@@ -102,6 +102,214 @@ describe("ChatMarkdown file option chips", () => {
102102 expect(html).toContain('aria-haspopup="menu"');
103103 expect(html).toContain("select-text");
104104 });
105
106 it.each([true, false])(
107 "renders Codex file citations as file chips with parseRawHtml=%s",
108 (parseRawHtml) => {
109 const html = renderToStaticMarkup(
110 <ChatMarkdown
111 cwd="/tmp/project"
112 text={
113 'Created :codex-file-citation{path="/tmp/project/outputs/report.xlsx" purpose="output"}.'
114 }
115 lineBreaks={!parseRawHtml}
116 parseRawHtml={parseRawHtml}
117 />,
118 );
119
120 expect(html).not.toContain("codex-file-citation");
121 expect(html).toContain("chat-markdown-file-link");
122 expect(html).toContain(
123 'data-markdown-copy="[report.xlsx](/tmp/project/outputs/report.xlsx)"',
124 );
125 expect(html).toContain("report.xlsx");
126 },
127 );
128
129 it("leaves an unfinished streaming citation visible until it is complete", () => {
130 const html = renderToStaticMarkup(
131 <ChatMarkdown
132 cwd="/tmp/project"
133 text={'Created :codex-file-citation{path="/tmp/project/outputs/report.xlsx"'}
134 isStreaming
135 />,
136 );
137
138 expect(html).toContain(":codex-file-citation");
139 expect(html).not.toContain("chat-markdown-file-link");
140 });
141
142 it("leaves malformed and similarly named file directives literal", () => {
143 for (const text of [
144 ':codex-file-citation{purpose="output"}',
145 ':codex-file-citation-extra{path="/tmp/project/outputs/report.xlsx"}',
146 ]) {
147 const html = renderToStaticMarkup(<ChatMarkdown cwd="/tmp/project" text={text} />);
148
149 expect(html).toContain(text.replaceAll('"', "&quot;"));
150 expect(html).not.toContain("chat-markdown-file-link");
151 }
152 });
153
154 it("preserves Codex file citation examples inside code", () => {
155 const directive = ':codex-file-citation{path="/tmp/project/outputs/report.xlsx"}';
156 const html = renderToStaticMarkup(
157 <ChatMarkdown
158 cwd="/tmp/project"
159 text={`Example: \`${directive}\`\n\n\`\`\`text\n${directive}\n\`\`\``}
160 />,
161 );
162
163 expect(html.match(/:codex-file-citation/g)).toHaveLength(2);
164 expect(html).not.toContain("chat-markdown-file-link");
165 });
166
167 it("preserves escaped Codex file citations as literal text", () => {
168 const html = renderToStaticMarkup(
169 <ChatMarkdown
170 cwd="/tmp/project"
171 text={'Example: \\:codex-file-citation{path="/tmp/project/outputs/report.xlsx"}'}
172 />,
173 );
174
175 expect(html).toContain(":codex-file-citation");
176 expect(html).not.toContain("chat-markdown-file-link");
177 });
178
179 it("does not create a nested link for citations inside link text", () => {
180 const directive = ':codex-file-citation{path="/tmp/project/outputs/report.xlsx"}';
181 const html = renderToStaticMarkup(
182 <ChatMarkdown cwd="/tmp/project" text={`[See ${directive}](https://example.com)`} />,
183 );
184 const renderedText = html.replace(/<[^>]+>/g, "");
185
186 expect(renderedText).toContain("codex-file-citation");
187 expect(html).not.toContain("chat-markdown-file-link");
188 });
189
190 it("renders file citations created by over-indented list recovery", () => {
191 const html = renderToStaticMarkup(
192 <ChatMarkdown
193 cwd="/tmp/project"
194 text={'- Created :codex-file-citation{path="/tmp/project/outputs/report.xlsx"}'}
195 />,
196 );
197
198 expect(html).not.toContain("<pre>");
199 expect(html).toContain("Created ");
200 expect(html).toContain("chat-markdown-file-link");
201 expect(html).toContain("report.xlsx");
202 });
203
204 it("disambiguates Codex citations with the same basename", () => {
205 const html = renderToStaticMarkup(
206 <ChatMarkdown
207 cwd="/tmp/project"
208 text={
209 'Changed :codex-file-citation{path="/tmp/project/src/index.ts"} and :codex-file-citation{path="/tmp/project/test/index.ts"}.'
210 }
211 />,
212 );
213
214 expect(html).toContain("index.ts · project/src");
215 expect(html).toContain("index.ts · project/test");
216 });
217
218 it("preserves rejected citations created by over-indented list recovery", () => {
219 const malformedHtml = renderToStaticMarkup(
220 <ChatMarkdown
221 cwd="/tmp/project"
222 text={'Leading text before list.\n\n- Bad :codex-file-citation{purpose="output"}'}
223 />,
224 );
225 const nestedLinkHtml = renderToStaticMarkup(
226 <ChatMarkdown
227 cwd="/tmp/project"
228 text={
229 'Leading text before list.\n\n- [Bad :codex-file-citation{path="/tmp/project/report.xlsx"}](https://example.com)'
230 }
231 />,
232 );
233 const nestedLinkText = nestedLinkHtml.replace(/<[^>]+>/g, "");
234
235 expect(malformedHtml).toContain(
236 "<li>Bad :codex-file-citation{purpose=&quot;output&quot;}</li>",
237 );
238 expect(nestedLinkText).toContain(
239 "Bad :codex-file-citation{path=&quot;/tmp/project/report.xlsx&quot;}",
240 );
241 });
242});
243
244const ARTIFACT_TEMPLATE_DIRECTIVE =
245 '::artifact-template{skill_name="artifact-template-hello-world" skill_directory="/Users/test/.codex/skills/artifact-template-hello-world" display_name="Hello World" artifact_kind="document"}';
246
247describe("ChatMarkdown artifact-template cards", () => {
248 it.each([true, false])("renders the Codex result card with parseRawHtml=%s", (parseRawHtml) => {
249 const html = renderToStaticMarkup(
250 <ChatMarkdown
251 cwd="/tmp/project"
252 text={ARTIFACT_TEMPLATE_DIRECTIVE}
253 parseRawHtml={parseRawHtml}
254 onUseArtifactTemplate={() => undefined}
255 />,
256 );
257
258 expect(html).not.toContain("::artifact-template");
259 expect(html).toContain("chat-markdown-artifact-template");
260 expect(html).toContain('data-artifact-kind="document"');
261 expect(html).toContain('data-markdown-copy="Hello World (Document template)\n\n"');
262 expect(html).toContain('data-skill-name="artifact-template-hello-world"');
263 expect(html).toContain("Hello World");
264 expect(html).toContain("Document template");
265 expect(html).toContain("Use template");
266 expect(html).not.toContain("<p><div");
267 });
268
269 it("renders a passive card outside a composer-backed timeline", () => {
270 const html = renderToStaticMarkup(
271 <ChatMarkdown cwd="/tmp/project" text={ARTIFACT_TEMPLATE_DIRECTIVE} />,
272 );
273
274 expect(html).toContain("chat-markdown-artifact-template");
275 expect(html).not.toContain("Use template");
276 });
277
278 it("leaves malformed and unfinished artifact-template directives literal", () => {
279 const malformed =
280 '::artifact-template{skill_name="artifact-template-hello-world" display_name="Hello World" artifact_kind="document"}';
281 const unfinished = ARTIFACT_TEMPLATE_DIRECTIVE.slice(0, -1);
282
283 for (const text of [malformed, unfinished]) {
284 const html = renderToStaticMarkup(<ChatMarkdown cwd="/tmp/project" text={text} />);
285 expect(html).toContain("::artifact-template");
286 expect(html).not.toContain("chat-markdown-artifact-template");
287 }
288 });
289
290 it("leaves escaped and similarly named artifact-template directives literal", () => {
291 for (const text of [
292 `\\${ARTIFACT_TEMPLATE_DIRECTIVE}`,
293 ARTIFACT_TEMPLATE_DIRECTIVE.replace("::artifact-template", "::artifact-template-extra"),
294 ]) {
295 const html = renderToStaticMarkup(<ChatMarkdown cwd="/tmp/project" text={text} />);
296
297 expect(html).toContain("::artifact-template");
298 expect(html).not.toContain("chat-markdown-artifact-template");
299 }
300 });
301
302 it("preserves artifact-template examples inside code", () => {
303 const html = renderToStaticMarkup(
304 <ChatMarkdown
305 cwd="/tmp/project"
306 text={`\`${ARTIFACT_TEMPLATE_DIRECTIVE}\`\n\n\`\`\`text\n${ARTIFACT_TEMPLATE_DIRECTIVE}\n\`\`\``}
307 />,
308 );
309
310 expect(html.match(/::artifact-template/g)).toHaveLength(2);
311 expect(html).not.toContain("chat-markdown-artifact-template");
312 });
105313});
106314
107315describe("shouldUseMarkdownFileBrowserPrimaryAction", () => {
apps/web/src/components/ChatMarkdown.tsx+94-1
......@@ -3,15 +3,23 @@ import {
33 CheckIcon,
44 ChevronRightIcon,
55 CopyIcon,
6 FileSpreadsheetIcon,
7 FileTextIcon,
68 GlobeIcon,
9 ImageIcon,
710 InfoIcon,
811 LightbulbIcon,
12 MailIcon,
913 Maximize2Icon,
14 MessageSquareIcon,
1015 MessageSquareWarningIcon,
1116 Minimize2Icon,
1217 OctagonAlertIcon,
18 PresentationIcon,
19 SparklesIcon,
1320 TriangleAlertIcon,
1421 WrapTextIcon,
22 type LucideIcon,
1523} from "lucide-react";
1624import type {
1725 EnvironmentId,
......@@ -24,6 +32,11 @@ import {
2432 squashAtomCommandFailure,
2533 type AtomCommandResult,
2634} from "@t3tools/client-runtime/state/runtime";
35import {
36 codexArtifactTemplatePresentationLabel,
37 type CodexArtifactTemplate,
38 type CodexArtifactTemplateKind,
39} from "@t3tools/client-runtime/codex-artifact-templates";
2740import {
2841 classifyMarkdownImageSource,
2942 markdownImageSourceFragment,
......@@ -55,6 +68,12 @@ import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
5568import remarkBreaks from "remark-breaks";
5669import remarkGfm from "remark-gfm";
5770import { remarkGithubAlerts } from "../markdown-github-alerts";
71import {
72 artifactTemplateFromHastProperties,
73 CODEX_ARTIFACT_TEMPLATE_HAST_PROPERTIES,
74 remarkCodexDirectives,
75 renderCodexFileCitationsAsMarkdown,
76} from "@t3tools/client-runtime/codex-markdown-directives";
5877import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText";
5978import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview";
6079import { CHAT_FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip";
......@@ -156,6 +175,8 @@ interface ChatMarkdownProps {
156175 lineBreaks?: boolean;
157176 /** Parse sanitized raw HTML instead of displaying its source text. */
158177 parseRawHtml?: boolean;
178 /** Append a prompt that invokes a newly created artifact-template skill. */
179 onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined;
159180 imageBaseDir?: string | undefined;
160181 onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined;
161182}
......@@ -191,6 +212,64 @@ export function shouldUseMarkdownFileBrowserPrimaryAction(input: {
191212
192213const EMPTY_MARKDOWN_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = [];
193214
215const ARTIFACT_TEMPLATE_ICON_BY_KIND = {
216 document: FileTextIcon,
217 presentation: PresentationIcon,
218 spreadsheet: FileSpreadsheetIcon,
219 site: GlobeIcon,
220 "google-docs": FileTextIcon,
221 "google-slides": PresentationIcon,
222 "google-sheets": FileSpreadsheetIcon,
223 image: ImageIcon,
224 email: MailIcon,
225 slack: MessageSquareIcon,
226} satisfies Record<CodexArtifactTemplateKind, LucideIcon>;
227
228function CodexArtifactTemplateCard(props: {
229 readonly template: CodexArtifactTemplate;
230 readonly onUse?: ((template: CodexArtifactTemplate) => void) | undefined;
231}) {
232 const Icon = ARTIFACT_TEMPLATE_ICON_BY_KIND[props.template.artifactKind];
233 const presentationLabel = codexArtifactTemplatePresentationLabel(props.template.artifactKind);
234
235 return (
236 <div
237 role="group"
238 aria-label={`${props.template.displayName} template`}
239 className="chat-markdown-artifact-template my-[0.65rem] flex w-full min-w-0 items-center gap-3 rounded-xl border border-border/70 bg-card/60 px-3 py-2.5 text-foreground shadow-xs"
240 data-artifact-kind={props.template.artifactKind}
241 data-markdown-copy={`${props.template.displayName} (${presentationLabel})\n\n`}
242 data-skill-name={props.template.skillName}
243 >
244 <div className="flex min-w-0 flex-1 items-center gap-3">
245 <span className="relative flex size-9 shrink-0 items-center justify-center rounded-lg border border-border/70 bg-background text-muted-foreground shadow-xs">
246 <Icon aria-hidden className="size-5" />
247 <span className="absolute -right-1 -bottom-1 flex size-4 items-center justify-center rounded-full border border-background bg-fuchsia-500 text-white shadow-xs">
248 <SparklesIcon aria-hidden className="size-2.5" />
249 </span>
250 </span>
251 <span className="min-w-0">
252 <span className="block truncate text-sm font-medium text-foreground">
253 {props.template.displayName}
254 </span>
255 <span className="block text-xs text-muted-foreground">{presentationLabel}</span>
256 </span>
257 </div>
258 {props.onUse ? (
259 <Button
260 type="button"
261 size="sm"
262 variant="outline"
263 className="shrink-0"
264 onClick={() => props.onUse?.(props.template)}
265 >
266 Use template
267 </Button>
268 ) : null}
269 </div>
270 );
271}
272
194273const CODE_FENCE_LANGUAGE_REGEX = /(?:^|\s)language-([^\s]+)/;
195274const WINDOWS_DRIVE_PATH_REGEX = /^[A-Za-z]:[\\/]/;
196275const MAX_HIGHLIGHT_CACHE_ENTRIES = 500;
......@@ -280,6 +359,7 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = {
280359 "*": (defaultSchema.attributes?.["*"] ?? []).filter((attribute) => attribute !== "title"),
281360 code: [...(defaultSchema.attributes?.code ?? []), "dataCodeMeta", "dataInlineCode"],
282361 blockquote: [...(defaultSchema.attributes?.blockquote ?? []), "dataAlert"],
362 div: [...(defaultSchema.attributes?.div ?? []), ...CODEX_ARTIFACT_TEMPLATE_HAST_PROPERTIES],
283363 img: [...(defaultSchema.attributes?.img ?? []), "dataLocalSrc", "dataMarkdownTitle"],
284364 },
285365 protocols: {
......@@ -293,6 +373,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS = [
293373 remarkGfm,
294374 remarkGithubAlerts,
295375 remarkNormalizeListItemIndentation,
376 remarkCodexDirectives,
296377 remarkPreserveCodeMeta,
297378 remarkNormalizeLinksAndTagInlineCode,
298379] satisfies NonNullable<ReactMarkdownOptions["remarkPlugins"]>;
......@@ -301,6 +382,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [
301382 remarkGfm,
302383 remarkGithubAlerts,
303384 remarkNormalizeListItemIndentation,
385 remarkCodexDirectives,
304386 remarkBreaks,
305387 remarkPreserveCodeMeta,
306388 remarkNormalizeLinksAndTagInlineCode,
......@@ -1727,6 +1809,7 @@ function ChatMarkdown({
17271809 className,
17281810 lineBreaks = false,
17291811 parseRawHtml = true,
1812 onUseArtifactTemplate,
17301813 imageBaseDir,
17311814 onImageExpand,
17321815}: ChatMarkdownProps) {
......@@ -1793,7 +1876,7 @@ function ChatMarkdown({
17931876 string,
17941877 NonNullable<ReturnType<typeof resolveMarkdownFileLinkMeta>>
17951878 >();
1796 for (const href of extractMarkdownLinkHrefs(text)) {
1879 for (const href of extractMarkdownLinkHrefs(renderCodexFileCitationsAsMarkdown(text))) {
17971880 const normalizedHref = normalizeMarkdownLinkHrefKey(href);
17981881 if (metaByHref.has(normalizedHref)) continue;
17991882 const meta = resolveMarkdownFileLinkMeta(normalizedHref, cwd);
......@@ -2037,6 +2120,15 @@ function ChatMarkdown({
20372120 };
20382121
20392122 return {
2123 div({ node, children, ...props }) {
2124 const artifactTemplate = artifactTemplateFromHastProperties(node?.properties);
2125 if (artifactTemplate) {
2126 return (
2127 <CodexArtifactTemplateCard template={artifactTemplate} onUse={onUseArtifactTemplate} />
2128 );
2129 }
2130 return <div {...props}>{children}</div>;
2131 },
20402132 p({ node: _node, children, ...props }) {
20412133 return <p {...props}>{renderSkillInlineMarkdownChildren(children, skills)}</p>;
20422134 },
......@@ -2332,6 +2424,7 @@ function ChatMarkdown({
23322424 isStreaming,
23332425 markdownFileLinkMetaByHref,
23342426 onTaskListChange,
2427 onUseArtifactTemplate,
23352428 onImageExpand,
23362429 openFileInPanel,
23372430 openInPreferredEditor,
apps/web/src/components/ChatView.logic.test.ts+16
......@@ -9,6 +9,7 @@ import {
99import { afterEach, describe, expect, it, vi } from "vite-plus/test";
1010
1111import type { Thread, ThreadShell } from "../types";
12import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates";
1213import {
1314 MAX_HIDDEN_MOUNTED_PREVIEW_THREADS,
1415 MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
......@@ -36,6 +37,7 @@ import {
3637 scheduleEnvironmentReconnectWarning,
3738 shoulderTabReserve,
3839 startNewThreadForProject,
40 codexArtifactTemplatePromptToAppend,
3941 shouldDockDraftHeroForSubmission,
4042 shouldReleaseTimelineAnchorForToolActivity,
4143 shouldShowBranchMismatchBanner,
......@@ -72,6 +74,20 @@ const environmentId = EnvironmentId.make("environment-local");
7274const projectId = ProjectId.make("project-1");
7375const threadId = ThreadId.make("thread-1");
7476const now = "2026-03-29T00:00:00.000Z";
77const helloWorldTemplate: CodexArtifactTemplate = {
78 artifactKind: "document",
79 displayName: "Hello World",
80 skillDirectory: "/Users/test/.codex/skills/artifact-template-hello-world",
81 skillName: "artifact-template-hello-world",
82};
83
84describe("artifact template composer insertion", () => {
85 it("does not insert an already-present prompt", () => {
86 const prompt = "Create a document using this $artifact-template-hello-world about…";
87
88 expect(codexArtifactTemplatePromptToAppend(prompt, helloWorldTemplate)).toBeNull();
89 });
90});
7591
7692describe("shoulderTabReserve", () => {
7793 it("ignores the top drawer when measuring the shoulder tab band", () => {
apps/web/src/components/ChatView.logic.ts+13
......@@ -12,6 +12,11 @@ import {
1212 type ThreadId,
1313 type TurnId,
1414} from "@t3tools/contracts";
15import {
16 appendCodexArtifactTemplateUsePrompt,
17 codexArtifactTemplateUsePrompt,
18 type CodexArtifactTemplate,
19} from "@t3tools/client-runtime/codex-artifact-templates";
1520import {
1621 type ChatMessage,
1722 isImageAttachment,
......@@ -39,6 +44,14 @@ export const ENVIRONMENT_RECONNECT_WARNING_GRACE_MS = 2_000;
3944
4045export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String);
4146
47export function codexArtifactTemplatePromptToAppend(
48 currentDraft: string,
49 template: CodexArtifactTemplate,
50): string | null {
51 return appendCodexArtifactTemplateUsePrompt(currentDraft, template) === currentDraft
52 ? null
53 : codexArtifactTemplateUsePrompt(template);
54}
4255export function shoulderTabReserve(overlay: HTMLElement): number {
4356 if (overlay.querySelector(".chat-composer-tasks-tab")) return 0;
4457 const tab = overlay.querySelector<HTMLElement>(".chat-composer-shoulder-tab");
apps/web/src/components/ChatView.tsx+22
......@@ -29,6 +29,7 @@ import {
2929 type EnvironmentConnectionPresentation,
3030} from "@t3tools/client-runtime/connection";
3131import { wasBootstrapThreadDeleted } from "@t3tools/client-runtime/errors";
32import { type CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates";
3233import {
3334 changeRequestAutoSettles,
3435 effectiveSettled,
......@@ -376,6 +377,7 @@ import {
376377 revokeUserMessagePreviewUrls,
377378 shouldWriteThreadErrorToCurrentServerThread,
378379 startNewThreadForProject,
380 codexArtifactTemplatePromptToAppend,
379381 waitForStartedServerThread,
380382} from "./ChatView.logic";
381383import type { ThreadSyncPhase } from "../threadSync";
......@@ -3126,6 +3128,25 @@ function ChatViewContent(props: ChatViewProps) {
31263128 focusComposer();
31273129 });
31283130 }, [focusComposer]);
3131 const useArtifactTemplate = useCallback(
3132 (template: CodexArtifactTemplate) => {
3133 const composer = composerRef.current;
3134 if (!composer) return;
3135
3136 const currentDraft = composer.getSendContext().prompt;
3137 const prompt = codexArtifactTemplatePromptToAppend(currentDraft, template);
3138 if (prompt !== null && !composer.insertTextAtEnd(prompt, { ensureLeadingBoundary: true })) {
3139 toastManager.add({
3140 type: "error",
3141 title: "Unable to add to chat",
3142 description: "The composer is busy; try again once it is ready.",
3143 });
3144 return;
3145 }
3146 scheduleComposerFocus();
3147 },
3148 [composerRef, scheduleComposerFocus],
3149 );
31293150 const addTerminalContextToDraft = useCallback(
31303151 (selection: TerminalContextSelection) => {
31313152 composerRef.current?.addTerminalContext(selection);
......@@ -7237,6 +7258,7 @@ function ChatViewContent(props: ChatViewProps) {
72377258 onOpenTurnDiff={onOpenTurnDiff}
72387259 revertTurnCountByUserMessageId={revertTurnCountByUserMessageId}
72397260 onRevertUserMessage={onRevertUserMessage}
7261 onUseArtifactTemplate={useArtifactTemplate}
72407262 isRevertingCheckpoint={isRevertingCheckpoint}
72417263 onImageExpand={onExpandTimelineImage}
72427264 onFileOpen={openFileAttachment}
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+17
......@@ -270,6 +270,23 @@ describe("resolveAssistantMessageCopyState", () => {
270270 visible: false,
271271 });
272272 });
273
274 it("copies the rendered representation of Codex directives", () => {
275 expect(
276 resolveAssistantMessageCopyState({
277 showCopyButton: true,
278 text: [
279 'Created :codex-file-citation{path="outputs/report.xlsx" purpose="output"}.',
280 "",
281 '::artifact-template{skill_name="artifact-template-hello-world" skill_directory="/Users/test/.codex/skills/artifact-template-hello-world" display_name="Hello World" artifact_kind="document"}',
282 ].join("\n"),
283 streaming: false,
284 }),
285 ).toEqual({
286 text: "Created [report.xlsx](<outputs/report.xlsx>).\n\nHello World (Document template)",
287 visible: true,
288 });
289 });
273290});
274291
275292describe("deriveMessagesTimelineRows", () => {
apps/web/src/components/chat/MessagesTimeline.logic.ts+4-2
......@@ -1,4 +1,5 @@
11import * as Equal from "effect/Equal";
2import { renderCodexDirectivesForCopy } from "@t3tools/client-runtime/codex-markdown-directives";
23import {
34 formatDuration,
45 workEntryDisplayIndicatesToolFailure,
......@@ -439,9 +440,10 @@ export function resolveAssistantMessageCopyState({
439440 streaming: boolean;
440441}) {
441442 const hasText = text !== null && text.trim().length > 0;
443 const visible = showCopyButton && hasText && !streaming;
442444 return {
443 text: hasText ? text : null,
444 visible: showCopyButton && hasText && !streaming,
445 text: hasText ? (visible ? renderCodexDirectivesForCopy(text) : text) : null,
446 visible,
445447 };
446448}
447449
apps/web/src/components/chat/MessagesTimeline.tsx+8
......@@ -7,6 +7,7 @@ import {
77 type TurnId,
88} from "@t3tools/contracts";
99import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";
10import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates";
1011import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label";
1112import type { AgentPanelModel } from "@t3tools/client-runtime/state/subagentRuntime";
1213import {
......@@ -16,6 +17,7 @@ import {
1617
1718const EMPTY_AGENT_PANEL_MODEL = emptyAgentPanelModel();
1819const NOOP_OPEN_AGENTS = () => {};
20const NOOP_USE_ARTIFACT_TEMPLATE = () => {};
1921const NOOP_OPEN_ATTACHMENT = (_attachment: ChatFileAttachment) => {};
2022import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";
2123import {
......@@ -154,6 +156,7 @@ interface TimelineRowSharedState {
154156 skills: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;
155157 activeThreadEnvironmentId: EnvironmentId;
156158 onRevertUserMessage: (messageId: MessageId) => void;
159 onUseArtifactTemplate: (template: CodexArtifactTemplate) => void;
157160 onImageExpand: (preview: ExpandedImagePreview) => void;
158161 onFileOpen: (attachment: ChatFileAttachment) => void;
159162 openingVideoAttachmentId: string | null;
......@@ -235,6 +238,7 @@ interface MessagesTimelineProps {
235238 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
236239 revertTurnCountByUserMessageId: Map<MessageId, number>;
237240 onRevertUserMessage: (messageId: MessageId) => void;
241 onUseArtifactTemplate?: (template: CodexArtifactTemplate) => void;
238242 isRevertingCheckpoint: boolean;
239243 onImageExpand: (preview: ExpandedImagePreview) => void;
240244 onFileOpen?: (attachment: ChatFileAttachment) => void;
......@@ -282,6 +286,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
282286 onOpenTurnDiff,
283287 revertTurnCountByUserMessageId,
284288 onRevertUserMessage,
289 onUseArtifactTemplate = NOOP_USE_ARTIFACT_TEMPLATE,
285290 isRevertingCheckpoint,
286291 onImageExpand,
287292 onFileOpen = NOOP_OPEN_ATTACHMENT,
......@@ -543,6 +548,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
543548 skills,
544549 activeThreadEnvironmentId,
545550 onRevertUserMessage,
551 onUseArtifactTemplate,
546552 onImageExpand,
547553 onFileOpen,
548554 openingVideoAttachmentId,
......@@ -561,6 +567,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
561567 skills,
562568 activeThreadEnvironmentId,
563569 onRevertUserMessage,
570 onUseArtifactTemplate,
564571 onImageExpand,
565572 onFileOpen,
566573 openingVideoAttachmentId,
......@@ -1253,6 +1260,7 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess
12531260 isStreaming={Boolean(row.message.streaming)}
12541261 lineBreaks={shouldPreserveAssistantLineBreaks(messageText)}
12551262 skills={ctx.skills}
1263 onUseArtifactTemplate={ctx.onUseArtifactTemplate}
12561264 onImageExpand={ctx.onImageExpand}
12571265 />
12581266 <AssistantChangedFilesSection
apps/web/src/markdown-clipboard.test.ts+20-4
......@@ -22,6 +22,7 @@ class FakeElement {
2222 constructor(
2323 readonly tagName: string,
2424 private readonly classNames: ReadonlyArray<string> = [],
25 private readonly attributes: Readonly<Record<string, string>> = {},
2526 ) {}
2627
2728 get localName(): string {
......@@ -37,12 +38,12 @@ class FakeElement {
3738 return this;
3839 }
3940
40 getAttribute(): string | null {
41 return null;
41 getAttribute(name: string): string | null {
42 return this.attributes[name] ?? null;
4243 }
4344
44 hasAttribute(): boolean {
45 return false;
45 hasAttribute(name: string): boolean {
46 return Object.hasOwn(this.attributes, name);
4647 }
4748}
4849
......@@ -92,4 +93,19 @@ describe("serializeRenderedMarkdownFragment", () => {
9293
9394 expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("first line\nsecond line");
9495 });
96
97 it("uses a rendered card's explicit Markdown copy representation", () => {
98 const card = new FakeElement("DIV", [], {
99 "data-markdown-copy": "Hello World (Document template)\n\n",
100 }).append(
101 new FakeElement("SPAN").append(new FakeText("Hello World")),
102 new FakeElement("SPAN").append(new FakeText("Document template")),
103 new FakeElement("BUTTON").append(new FakeText("Use template")),
104 );
105 const container = new FakeElement("DIV").append(card);
106
107 expect(serializeRenderedMarkdownFragment(asNode(container))).toBe(
108 "Hello World (Document template)",
109 );
110 });
95111});
packages/client-runtime/package.json+19-1
......@@ -19,6 +19,18 @@
1919 "types": "./src/markdownImages.ts",
2020 "default": "./src/markdownImages.ts"
2121 },
22 "./codex-file-citations": {
23 "types": "./src/codexFileCitations.ts",
24 "default": "./src/codexFileCitations.ts"
25 },
26 "./codex-artifact-templates": {
27 "types": "./src/codexArtifactTemplates.ts",
28 "default": "./src/codexArtifactTemplates.ts"
29 },
30 "./codex-markdown-directives": {
31 "types": "./src/codexMarkdownDirectives.ts",
32 "default": "./src/codexMarkdownDirectives.ts"
33 },
2234 "./errors": {
2335 "types": "./src/errors/index.ts",
2436 "default": "./src/errors/index.ts"
......@@ -175,10 +187,16 @@
175187 "dependencies": {
176188 "@t3tools/contracts": "workspace:*",
177189 "@t3tools/shared": "workspace:*",
178 "effect": "catalog:"
190 "effect": "catalog:",
191 "mdast-util-directive": "^3.1.0",
192 "micromark-extension-directive": "^4.0.0",
193 "micromark-util-character": "^2.1.1",
194 "remark-parse": "^11.0.0",
195 "unified": "^11.0.5"
179196 },
180197 "devDependencies": {
181198 "@effect/vitest": "catalog:",
199 "micromark-util-types": "^2.0.2",
182200 "vite-plus": "catalog:"
183201 }
184202}
packages/client-runtime/src/codexArtifactTemplates.test.ts created+119
......@@ -0,0 +1,119 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 appendCodexArtifactTemplateUsePrompt,
5 codexArtifactTemplatePresentationLabel,
6 codexArtifactTemplateUsePrompt,
7 resolveCodexArtifactTemplate,
8 type CodexArtifactTemplate,
9} from "./codexArtifactTemplates.js";
10
11const HELLO_WORLD_TEMPLATE: CodexArtifactTemplate = {
12 artifactKind: "document",
13 displayName: "Hello World",
14 skillDirectory: "/Users/test/.codex/skills/artifact-template-hello-world",
15 skillName: "artifact-template-hello-world",
16};
17
18describe("artifact template presentation", () => {
19 it("shares labels and copy text across clients", () => {
20 expect(codexArtifactTemplatePresentationLabel("document")).toBe("Document template");
21 });
22});
23
24describe("resolveCodexArtifactTemplate", () => {
25 it("accepts the template metadata emitted by Codex", () => {
26 expect(
27 resolveCodexArtifactTemplate({
28 artifact_kind: "document",
29 display_name: " Hello World ",
30 skill_directory: "/Users/test/.codex/skills/artifact-template-hello-world",
31 skill_name: "artifact-template-hello-world",
32 }),
33 ).toEqual(HELLO_WORLD_TEMPLATE);
34 });
35
36 it.each([
37 String.raw`C:\Users\test\.codex\skills\artifact-template-hello-world`,
38 String.raw`\\server\share\artifact-template-hello-world`,
39 "//server/share/artifact-template-hello-world",
40 ])("accepts absolute Windows skill directories: %s", (skillDirectory) => {
41 expect(
42 resolveCodexArtifactTemplate({
43 artifact_kind: "image",
44 display_name: "Reference image",
45 gallery_kind: "product-design",
46 skill_directory: skillDirectory,
47 skill_name: "artifact-template-reference-image",
48 }),
49 ).toMatchObject({
50 artifactKind: "image",
51 galleryKind: "product-design",
52 skillDirectory,
53 });
54 });
55
56 it.each([
57 { artifact_kind: "unknown" },
58 { display_name: " " },
59 { skill_directory: "relative/template" },
60 { skill_name: "hello-world" },
61 { gallery_kind: null },
62 { gallery_kind: "unknown" },
63 ])("rejects malformed template metadata: %o", (override) => {
64 expect(
65 resolveCodexArtifactTemplate({
66 artifact_kind: "document",
67 display_name: "Hello World",
68 skill_directory: "/templates/hello-world",
69 skill_name: "artifact-template-hello-world",
70 ...override,
71 }),
72 ).toBeNull();
73 });
74});
75
76describe("codexArtifactTemplateUsePrompt", () => {
77 it("builds the same document follow-up shape as Codex", () => {
78 expect(codexArtifactTemplateUsePrompt(HELLO_WORLD_TEMPLATE)).toBe(
79 "Create a document using this $artifact-template-hello-world about…",
80 );
81 });
82
83 it("uses the artifact-specific image wording", () => {
84 expect(
85 codexArtifactTemplateUsePrompt({
86 ...HELLO_WORLD_TEMPLATE,
87 artifactKind: "image",
88 }),
89 ).toBe("Create an image using this $artifact-template-hello-world of…");
90 });
91});
92
93describe("appendCodexArtifactTemplateUsePrompt", () => {
94 const prompt = "Create a document using this $artifact-template-hello-world about…";
95
96 it("adds the prompt to an empty draft", () => {
97 expect(appendCodexArtifactTemplateUsePrompt("", HELLO_WORLD_TEMPLATE)).toBe(prompt);
98 });
99
100 it("preserves existing draft text", () => {
101 expect(appendCodexArtifactTemplateUsePrompt("Write about otters", HELLO_WORLD_TEMPLATE)).toBe(
102 `Write about otters ${prompt}`,
103 );
104 });
105
106 it.each([prompt, `${prompt}\n`, `Notes\n\n${prompt}`])(
107 "does not append the same final prompt twice: %s",
108 (draft) => {
109 expect(appendCodexArtifactTemplateUsePrompt(draft, HELLO_WORLD_TEMPLATE)).toBe(draft);
110 },
111 );
112
113 it("does not mistake text containing the prompt for a final prompt", () => {
114 const draft = `${prompt}\nAdditional instructions`;
115 expect(appendCodexArtifactTemplateUsePrompt(draft, HELLO_WORLD_TEMPLATE)).toBe(
116 `${draft} ${prompt}`,
117 );
118 });
119});
packages/client-runtime/src/codexArtifactTemplates.ts created+136
......@@ -0,0 +1,136 @@
1export const CODEX_ARTIFACT_TEMPLATE_KINDS = [
2 "document",
3 "presentation",
4 "spreadsheet",
5 "site",
6 "google-docs",
7 "google-slides",
8 "google-sheets",
9 "image",
10 "email",
11 "slack",
12] as const;
13
14export type CodexArtifactTemplateKind = (typeof CODEX_ARTIFACT_TEMPLATE_KINDS)[number];
15
16export const CODEX_ARTIFACT_TEMPLATE_GALLERY_KINDS = ["imagegen", "product-design"] as const;
17
18export type CodexArtifactTemplateGalleryKind =
19 (typeof CODEX_ARTIFACT_TEMPLATE_GALLERY_KINDS)[number];
20
21export interface CodexArtifactTemplate {
22 readonly artifactKind: CodexArtifactTemplateKind;
23 readonly displayName: string;
24 readonly galleryKind?: CodexArtifactTemplateGalleryKind;
25 readonly skillDirectory: string;
26 readonly skillName: string;
27}
28
29export const CODEX_ARTIFACT_TEMPLATE_LABEL_BY_KIND = {
30 document: "Document template",
31 presentation: "Presentation template",
32 spreadsheet: "Spreadsheet template",
33 site: "Site template",
34 "google-docs": "Google Doc template",
35 "google-slides": "Google Slides template",
36 "google-sheets": "Google Sheet template",
37 image: "Image template",
38 email: "Email template",
39 slack: "Slack template",
40} satisfies Record<CodexArtifactTemplateKind, string>;
41
42export type CodexArtifactTemplateAttributes = Readonly<Record<string, string | null | undefined>>;
43
44const WINDOWS_DRIVE_PATH_REGEX = /^[A-Za-z]:[\\/]/;
45const WINDOWS_UNC_PATH_REGEX = /^(?:\\\\[^\\]+\\[^\\]+|\/\/[^/]+\/[^/]+)/;
46
47function isCodexArtifactTemplateKind(value: unknown): value is CodexArtifactTemplateKind {
48 return CODEX_ARTIFACT_TEMPLATE_KINDS.some((kind) => kind === value);
49}
50
51function isCodexArtifactTemplateGalleryKind(
52 value: unknown,
53): value is CodexArtifactTemplateGalleryKind {
54 return CODEX_ARTIFACT_TEMPLATE_GALLERY_KINDS.some((kind) => kind === value);
55}
56
57function isAbsoluteSkillDirectory(value: string): boolean {
58 return (
59 (value.startsWith("/") && !value.startsWith("//")) ||
60 WINDOWS_DRIVE_PATH_REGEX.test(value) ||
61 WINDOWS_UNC_PATH_REGEX.test(value)
62 );
63}
64
65/** Mirrors the Codex result-card schema so malformed directives remain literal Markdown. */
66export function resolveCodexArtifactTemplate(
67 attributes: CodexArtifactTemplateAttributes | null | undefined,
68): CodexArtifactTemplate | null {
69 const artifactKind = attributes?.artifact_kind;
70 const displayNameValue = attributes?.display_name;
71 const displayName = typeof displayNameValue === "string" ? displayNameValue.trim() : undefined;
72 const galleryKind = attributes?.gallery_kind;
73 const skillDirectory = attributes?.skill_directory;
74 const skillName = attributes?.skill_name;
75
76 if (
77 !isCodexArtifactTemplateKind(artifactKind) ||
78 !displayName ||
79 typeof skillDirectory !== "string" ||
80 !isAbsoluteSkillDirectory(skillDirectory) ||
81 typeof skillName !== "string" ||
82 !skillName.startsWith("artifact-template-") ||
83 (galleryKind !== undefined && !isCodexArtifactTemplateGalleryKind(galleryKind))
84 ) {
85 return null;
86 }
87
88 return {
89 artifactKind,
90 displayName,
91 ...(galleryKind === undefined ? {} : { galleryKind }),
92 skillDirectory,
93 skillName,
94 };
95}
96
97const USE_PROMPT_BY_KIND: Record<CodexArtifactTemplateKind, (skill: string) => string> = {
98 document: (skill) => `Create a document using this ${skill} about…`,
99 presentation: (skill) => `Create a presentation using the ${skill} template about…`,
100 spreadsheet: (skill) => `Create a spreadsheet using this ${skill} about…`,
101 site: (skill) => `Create a Site using this ${skill} about…`,
102 "google-docs": (skill) => `Create a Google Doc using this ${skill} about…`,
103 "google-slides": (skill) => `Create a Google Slides presentation using this ${skill} about…`,
104 "google-sheets": (skill) => `Create a Google Sheet using this ${skill} about…`,
105 image: (skill) => `Create an image using this ${skill} of…`,
106 email: (skill) => `Draft an email using this ${skill} about…`,
107 slack: (skill) => `Draft a Slack message using this ${skill} about…`,
108};
109
110export function codexArtifactTemplateUsePrompt(template: CodexArtifactTemplate): string {
111 return USE_PROMPT_BY_KIND[template.artifactKind](`$${template.skillName}`);
112}
113
114export function codexArtifactTemplatePresentationLabel(kind: CodexArtifactTemplateKind): string {
115 return CODEX_ARTIFACT_TEMPLATE_LABEL_BY_KIND[kind];
116}
117
118export function appendCodexArtifactTemplateUsePrompt(
119 draft: string,
120 template: CodexArtifactTemplate,
121): string {
122 const prompt = codexArtifactTemplateUsePrompt(template);
123 const trimmedDraft = draft.trimEnd();
124 const promptStart = trimmedDraft.length - prompt.length;
125 const alreadyEndsWithPrompt =
126 promptStart >= 0 &&
127 trimmedDraft.slice(promptStart) === prompt &&
128 (promptStart === 0 || /\s/.test(trimmedDraft[promptStart - 1] ?? ""));
129
130 if (alreadyEndsWithPrompt) {
131 return draft;
132 }
133
134 const needsLeadingSpace = draft.length > 0 && !/\s$/.test(draft);
135 return `${draft}${needsLeadingSpace ? " " : ""}${prompt}`;
136}
packages/client-runtime/src/codexFileCitations.test.ts created+77
......@@ -0,0 +1,77 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { codexFileCitationMarkdown, resolveCodexFileCitationLink } from "./codexFileCitations.js";
4
5describe("resolveCodexFileCitationLink", () => {
6 it("resolves the attributes emitted by Codex", () => {
7 expect(
8 resolveCodexFileCitationLink({
9 path: "/workspace/outputs/issue-2387-sparse-diagonal.xlsx",
10 purpose: "output",
11 }),
12 ).toEqual({
13 path: "/workspace/outputs/issue-2387-sparse-diagonal.xlsx",
14 href: "/workspace/outputs/issue-2387-sparse-diagonal.xlsx",
15 label: "issue-2387-sparse-diagonal.xlsx",
16 });
17 });
18
19 it("carries the first cited line into the file href", () => {
20 expect(
21 resolveCodexFileCitationLink({
22 path: "src/main.ts",
23 line_range_start: "42",
24 line_range_end: "48",
25 git_url: "https://example.com/main.ts",
26 }),
27 ).toEqual({
28 path: "src/main.ts",
29 href: "src/main.ts#L42",
30 label: "main.ts",
31 lineRangeStart: 42,
32 });
33 });
34
35 it("rejects missing paths and invalid line numbers", () => {
36 expect(resolveCodexFileCitationLink({ purpose: "output" })).toBeNull();
37 expect(
38 resolveCodexFileCitationLink({ path: "src/main.ts", line_range_start: "not-a-line" }),
39 ).toEqual({
40 path: "src/main.ts",
41 href: "src/main.ts",
42 label: "main.ts",
43 });
44 });
45
46 it("preserves URL syntax characters in file paths", () => {
47 expect(
48 resolveCodexFileCitationLink({
49 path: "reports/100% #1? draft.md",
50 line_range_start: "7",
51 }),
52 ).toEqual({
53 path: "reports/100% #1? draft.md",
54 href: "reports/100%25 %231%3F draft.md#L7",
55 label: "100% #1? draft.md",
56 lineRangeStart: 7,
57 });
58 });
59});
60
61describe("codexFileCitationMarkdown", () => {
62 it("produces a portable Markdown link", () => {
63 const citation = resolveCodexFileCitationLink({ path: "reports/profit and loss.xlsx" });
64 expect(citation && codexFileCitationMarkdown(citation)).toBe(
65 "[profit and loss.xlsx](<reports/profit and loss.xlsx>)",
66 );
67 });
68
69 it("escapes Markdown syntax in the visible filename", () => {
70 const citation = resolveCodexFileCitationLink({
71 path: "reports/*draft*_[copy]`<&.txt",
72 });
73 expect(citation && codexFileCitationMarkdown(citation)).toBe(
74 "[\\*draft\\*\\_\\[copy\\]\\`\\<\\&.txt](<reports/*draft*_[copy]`%3C&.txt>)",
75 );
76 });
77});
packages/client-runtime/src/codexFileCitations.ts created+55
......@@ -0,0 +1,55 @@
1export interface CodexFileCitationLink {
2 readonly path: string;
3 readonly href: string;
4 readonly label: string;
5 readonly lineRangeStart?: number;
6}
7
8export type CodexFileCitationAttributes = Readonly<Record<string, string | null | undefined>>;
9
10function positiveInteger(value: string | null | undefined): number | undefined {
11 if (value === null || value === undefined || value.trim().length === 0) return undefined;
12 const parsed = Number(value);
13 return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
14}
15
16function citationLabel(path: string): string {
17 const normalized = path.replaceAll("\\", "/").replace(/\/+$/, "");
18 return normalized.slice(normalized.lastIndexOf("/") + 1) || normalized || "File";
19}
20
21function markdownDestinationPath(path: string): string {
22 return path.replaceAll("%", "%25").replaceAll("#", "%23").replaceAll("?", "%3F");
23}
24
25export function resolveCodexFileCitationLink(
26 attributes: CodexFileCitationAttributes | null | undefined,
27): CodexFileCitationLink | null {
28 const path = attributes?.path?.trim();
29 if (!path) return null;
30
31 const lineRangeStart = positiveInteger(attributes?.line_range_start);
32 const destinationPath = markdownDestinationPath(path);
33 return {
34 path,
35 href: lineRangeStart === undefined ? destinationPath : `${destinationPath}#L${lineRangeStart}`,
36 label: citationLabel(path),
37 ...(lineRangeStart === undefined ? {} : { lineRangeStart }),
38 };
39}
40
41function markdownLabel(value: string): string {
42 return value.replace(/[\\[\]*_`<&]/g, "\\$&");
43}
44
45function markdownDestination(value: string): string {
46 return value
47 .replaceAll("<", "%3C")
48 .replaceAll(">", "%3E")
49 .replaceAll("\r", "%0D")
50 .replaceAll("\n", "%0A");
51}
52
53export function codexFileCitationMarkdown(citation: CodexFileCitationLink): string {
54 return `[${markdownLabel(citation.label)}](<${markdownDestination(citation.href)}>)`;
55}
packages/client-runtime/src/codexMarkdownDirectives.test.ts created+156
......@@ -0,0 +1,156 @@
1import { describe, expect, it } from "vite-plus/test";
2import remarkParse from "remark-parse";
3import { unified } from "unified";
4
5import {
6 remarkCodexDirectives,
7 renderCodexDirectivesForCopy,
8 renderCodexFileCitationsAsMarkdown,
9 splitCodexArtifactTemplateMarkdown,
10} from "./codexMarkdownDirectives.js";
11
12interface TestNode {
13 readonly type: string;
14 readonly value?: string;
15 readonly url?: string;
16 readonly position?: {
17 readonly start: { readonly offset?: number };
18 readonly end: { readonly offset?: number };
19 };
20 readonly data?: {
21 readonly hName?: string;
22 readonly hProperties?: Readonly<Record<string, unknown>>;
23 };
24 readonly children?: readonly TestNode[];
25}
26
27const FILE_CITATION = ':codex-file-citation{path="outputs/report.xlsx" purpose="output"}';
28const ARTIFACT_TEMPLATE =
29 '::artifact-template{skill_name="artifact-template-hello-world" skill_directory="/Users/test/.codex/skills/artifact-template-hello-world" display_name="Hello World" artifact_kind="document"}';
30
31function parse(markdown: string): TestNode {
32 const processor = unified().use(remarkParse).use(remarkCodexDirectives);
33 return processor.runSync(processor.parse(markdown), { value: markdown }) as TestNode;
34}
35
36function parseOrdinaryMarkdown(markdown: string): TestNode {
37 return unified().use(remarkParse).parse(markdown) as TestNode;
38}
39
40describe("remarkCodexDirectives", () => {
41 it("renders a file citation as a link without changing its source position", () => {
42 const markdown = `Created ${FILE_CITATION}.`;
43 const link = parse(markdown).children?.[0]?.children?.[1];
44
45 expect(link).toMatchObject({
46 type: "link",
47 url: "outputs/report.xlsx",
48 children: [{ type: "text", value: "report.xlsx" }],
49 position: {
50 start: { offset: markdown.indexOf(FILE_CITATION) },
51 end: { offset: markdown.indexOf(FILE_CITATION) + FILE_CITATION.length },
52 },
53 });
54 });
55
56 it("renders an artifact template as semantic block metadata", () => {
57 expect(parse(ARTIFACT_TEMPLATE).children?.[0]).toMatchObject({
58 type: "paragraph",
59 children: [],
60 data: {
61 hName: "div",
62 hProperties: {
63 dataCodexArtifactTemplate: "true",
64 dataArtifactKind: "document",
65 dataDisplayName: "Hello World",
66 dataSkillName: "artifact-template-hello-world",
67 },
68 },
69 });
70 });
71
72 it.each([
73 "Meeting at 10:30",
74 "Open src/main.ts:42",
75 "Use :hover and :tada:",
76 "::note",
77 ":::note\ncontent\n:::",
78 ':codex-file-citation-extra{path="outputs/report.xlsx"}',
79 "::artifact-template-extra",
80 ])("does not change unrelated colon syntax: %s", (markdown) => {
81 expect(parse(markdown)).toEqual(parseOrdinaryMarkdown(markdown));
82 });
83
84 it.each([
85 ':codex-file-citation{purpose="output"}',
86 '::artifact-template{skill_name="artifact-template-hello-world"}',
87 ])("keeps malformed supported directives literal: %s", (markdown) => {
88 expect(parse(markdown)).toEqual(parseOrdinaryMarkdown(markdown));
89 });
90});
91
92describe("native Markdown adapters", () => {
93 it("uses the same parser to render file citations as portable links", () => {
94 expect(renderCodexFileCitationsAsMarkdown(`Created ${FILE_CITATION}.`)).toBe(
95 "Created [report.xlsx](<outputs/report.xlsx>).",
96 );
97 });
98
99 it.each([
100 `\\${FILE_CITATION}`,
101 `\`${FILE_CITATION}\``,
102 `\`\`\`text\n${FILE_CITATION}\n\`\`\``,
103 `[See ${FILE_CITATION}](https://example.com)`,
104 ])("does not render excluded citation syntax: %s", (markdown) => {
105 expect(renderCodexFileCitationsAsMarkdown(markdown)).toBe(markdown);
106 });
107
108 it("splits artifact cards from surrounding native Markdown", () => {
109 expect(splitCodexArtifactTemplateMarkdown(`Before\n\n${ARTIFACT_TEMPLATE}\n\nAfter`)).toEqual([
110 { kind: "markdown", markdown: "Before\n\n", sourceOffset: 0 },
111 {
112 kind: "artifact-template",
113 sourceOffset: 8,
114 template: {
115 artifactKind: "document",
116 displayName: "Hello World",
117 skillDirectory: "/Users/test/.codex/skills/artifact-template-hello-world",
118 skillName: "artifact-template-hello-world",
119 },
120 },
121 {
122 kind: "markdown",
123 markdown: "\n\nAfter",
124 sourceOffset: 8 + ARTIFACT_TEMPLATE.length,
125 },
126 ]);
127 });
128
129 it("leaves malformed and code artifact-template examples in Markdown", () => {
130 const malformed = '::artifact-template{display_name="Hello World"}';
131 const code = `\`${ARTIFACT_TEMPLATE}\``;
132 expect(splitCodexArtifactTemplateMarkdown(malformed)).toEqual([
133 { kind: "markdown", markdown: malformed, sourceOffset: 0 },
134 ]);
135 expect(splitCodexArtifactTemplateMarkdown(code)).toEqual([
136 { kind: "markdown", markdown: code, sourceOffset: 0 },
137 ]);
138 });
139});
140
141describe("directive copy adapter", () => {
142 it("copies the Markdown representations shown by citation chips and template cards", () => {
143 expect(renderCodexDirectivesForCopy(`Created ${FILE_CITATION}.\n\n${ARTIFACT_TEMPLATE}`)).toBe(
144 "Created [report.xlsx](<outputs/report.xlsx>).\n\nHello World (Document template)",
145 );
146 });
147
148 it("leaves excluded and malformed directive source unchanged", () => {
149 const markdown = [
150 `\`${FILE_CITATION}\``,
151 '::artifact-template{display_name="Hello World"}',
152 ].join("\n\n");
153
154 expect(renderCodexDirectivesForCopy(markdown)).toBe(markdown);
155 });
156});
packages/client-runtime/src/codexMarkdownDirectives.ts created+388
......@@ -0,0 +1,388 @@
1import { directiveFromMarkdown } from "mdast-util-directive";
2import { directive } from "micromark-extension-directive";
3import {
4 markdownLineEnding,
5 unicodePunctuation,
6 unicodeWhitespace,
7} from "micromark-util-character";
8import type { Construct, Extension, Tokenizer } from "micromark-util-types";
9import remarkParse from "remark-parse";
10import { unified, type Processor } from "unified";
11
12import {
13 codexArtifactTemplatePresentationLabel,
14 resolveCodexArtifactTemplate,
15 type CodexArtifactTemplate,
16} from "./codexArtifactTemplates.ts";
17import { codexFileCitationMarkdown, resolveCodexFileCitationLink } from "./codexFileCitations.ts";
18
19const COLON = 58;
20const DASH = 45;
21const UNDERSCORE = 95;
22const CODEX_FILE_CITATION_NAME = "codex-file-citation";
23const CODEX_ARTIFACT_TEMPLATE_NAME = "artifact-template";
24
25export const CODEX_ARTIFACT_TEMPLATE_HAST_PROPERTIES = [
26 "dataCodexArtifactTemplate",
27 "dataArtifactKind",
28 "dataDisplayName",
29 "dataGalleryKind",
30 "dataSkillDirectory",
31 "dataSkillName",
32] as const;
33
34interface MarkdownPosition {
35 readonly start: { readonly offset?: number };
36 readonly end: { readonly offset?: number };
37}
38
39interface MarkdownAstNode {
40 type?: string;
41 name?: string;
42 value?: string;
43 url?: string;
44 attributes?: Readonly<Record<string, string | null>>;
45 position?: MarkdownPosition;
46 data?: {
47 codexArtifactTemplate?: CodexArtifactTemplate;
48 codexFileCitationMarkdown?: string;
49 hName?: string;
50 hProperties?: Record<string, unknown>;
51 };
52 children?: MarkdownAstNode[];
53}
54
55interface MarkdownFile {
56 readonly value: unknown;
57}
58
59export type CodexArtifactTemplateMarkdownSegment =
60 | { readonly kind: "markdown"; readonly markdown: string; readonly sourceOffset: number }
61 | {
62 readonly kind: "artifact-template";
63 readonly sourceOffset: number;
64 readonly template: CodexArtifactTemplate;
65 };
66
67function asConstruct(value: Construct | Construct[] | undefined, label: string): Construct {
68 const construct = Array.isArray(value) ? value[0] : value;
69 if (!construct) throw new Error(`Missing ${label} directive construct`);
70 return construct;
71}
72
73function directiveNameEnds(code: number | null): boolean {
74 return (
75 code === null ||
76 markdownLineEnding(code) ||
77 unicodeWhitespace(code) ||
78 (unicodePunctuation(code) && code !== DASH && code !== UNDERSCORE)
79 );
80}
81
82function directiveNameGate(markerCount: number, name: string): Construct {
83 const tokenize: Tokenizer = (effects, ok, nok) => {
84 let markerIndex = 0;
85 let nameIndex = 0;
86
87 return marker;
88
89 function marker(code: number | null) {
90 if (code !== COLON) return nok(code);
91 if (markerIndex === 0) effects.enter("data");
92 effects.consume(code);
93 markerIndex += 1;
94 return markerIndex === markerCount ? nameCharacter : marker;
95 }
96
97 function nameCharacter(code: number | null) {
98 if (code !== name.charCodeAt(nameIndex)) return nok(code);
99 effects.consume(code);
100 nameIndex += 1;
101 return nameIndex === name.length ? afterName : nameCharacter;
102 }
103
104 function afterName(code: number | null) {
105 effects.exit("data");
106 return directiveNameEnds(code) ? ok(code) : nok(code);
107 }
108 };
109
110 return { partial: true, tokenize };
111}
112
113function restrictedDirective(construct: Construct, markerCount: number, name: string): Construct {
114 const gate = directiveNameGate(markerCount, name);
115 return {
116 ...construct,
117 tokenize(effects, ok, nok) {
118 return effects.check(gate, construct.tokenize.call(this, effects, ok, nok), nok);
119 },
120 };
121}
122
123function codexDirectiveSyntax(): Extension {
124 const genericSyntax = directive();
125 const textDirective = asConstruct(genericSyntax.text?.[COLON], CODEX_FILE_CITATION_NAME);
126 const flowDirectives = genericSyntax.flow?.[COLON];
127 const leafDirective = Array.isArray(flowDirectives)
128 ? flowDirectives.find((construct) => construct.concrete !== true)
129 : flowDirectives;
130 if (!leafDirective)
131 throw new Error(`Missing ${CODEX_ARTIFACT_TEMPLATE_NAME} directive construct`);
132
133 return {
134 text: {
135 [COLON]: restrictedDirective(textDirective, 1, CODEX_FILE_CITATION_NAME),
136 },
137 flow: {
138 [COLON]: restrictedDirective(leafDirective, 2, CODEX_ARTIFACT_TEMPLATE_NAME),
139 },
140 };
141}
142
143const CODEX_DIRECTIVE_SYNTAX = codexDirectiveSyntax();
144const CODEX_DIRECTIVE_FROM_MARKDOWN = directiveFromMarkdown();
145
146function sourceForNode(node: MarkdownAstNode, source: string): string {
147 const start = node.position?.start.offset;
148 const end = node.position?.end.offset;
149 return start === undefined || end === undefined ? "" : source.slice(start, end);
150}
151
152function sourceForDirective(node: MarkdownAstNode, source: string, marker: ":" | "::"): string {
153 const prefix = `${marker}${node.name ?? ""}`;
154 const slicedSource = sourceForNode(node, source);
155 if (slicedSource.startsWith(prefix)) return slicedSource;
156
157 const attributes = Object.entries(node.attributes ?? {}).map(([name, value]) =>
158 value === null ? name : `${name}=${JSON.stringify(value)}`,
159 );
160 return `${prefix}${attributes.length === 0 ? "" : `{${attributes.join(" ")}}`}`;
161}
162
163function restoreTextDirective(node: MarkdownAstNode, source: string): void {
164 node.type = "text";
165 node.value = sourceForDirective(node, source, ":");
166 delete node.name;
167 delete node.attributes;
168 delete node.url;
169 delete node.data;
170 delete node.children;
171}
172
173function restoreLeafDirective(node: MarkdownAstNode, source: string): void {
174 const value = sourceForDirective(node, source, "::");
175 node.type = "paragraph";
176 node.children = [
177 { type: "text", value, ...(node.position === undefined ? {} : { position: node.position }) },
178 ];
179 delete node.name;
180 delete node.attributes;
181 delete node.value;
182 delete node.url;
183 delete node.data;
184}
185
186function renderFileCitation(node: MarkdownAstNode, source: string, insideLink: boolean): void {
187 const citation = resolveCodexFileCitationLink(node.attributes);
188 if (!citation || insideLink) {
189 restoreTextDirective(node, source);
190 return;
191 }
192
193 node.type = "link";
194 node.url = citation.href;
195 node.children = [{ type: "text", value: citation.label }];
196 node.data = { codexFileCitationMarkdown: codexFileCitationMarkdown(citation) };
197 delete node.name;
198 delete node.attributes;
199 delete node.value;
200}
201
202function renderArtifactTemplate(node: MarkdownAstNode, source: string): void {
203 const template = resolveCodexArtifactTemplate(node.attributes);
204 if (!template) {
205 restoreLeafDirective(node, source);
206 return;
207 }
208
209 node.type = "paragraph";
210 node.children = [];
211 node.data = {
212 codexArtifactTemplate: template,
213 hName: "div",
214 hProperties: {
215 dataCodexArtifactTemplate: "true",
216 dataArtifactKind: template.artifactKind,
217 dataDisplayName: template.displayName,
218 ...(template.galleryKind === undefined ? {} : { dataGalleryKind: template.galleryKind }),
219 dataSkillDirectory: template.skillDirectory,
220 dataSkillName: template.skillName,
221 },
222 };
223 delete node.name;
224 delete node.attributes;
225 delete node.value;
226 delete node.url;
227}
228
229function transformCodexDirectives(node: MarkdownAstNode, source: string, insideLink = false): void {
230 if (node.type === "textDirective" && node.name === CODEX_FILE_CITATION_NAME) {
231 renderFileCitation(node, source, insideLink);
232 return;
233 }
234 if (node.type === "leafDirective" && node.name === CODEX_ARTIFACT_TEMPLATE_NAME) {
235 renderArtifactTemplate(node, source);
236 return;
237 }
238
239 const childrenInsideLink = insideLink || node.type === "link" || node.type === "linkReference";
240 for (const child of node.children ?? []) {
241 transformCodexDirectives(child, source, childrenInsideLink);
242 }
243}
244
245/** Adds grammar only for the two directives emitted by Codex, then renders them as mdast. */
246function attachCodexDirectives(this: Processor) {
247 const data = this.data();
248 const micromarkExtensions = data.micromarkExtensions ?? (data.micromarkExtensions = []);
249 const fromMarkdownExtensions = data.fromMarkdownExtensions ?? (data.fromMarkdownExtensions = []);
250 micromarkExtensions.push(CODEX_DIRECTIVE_SYNTAX);
251 fromMarkdownExtensions.push(CODEX_DIRECTIVE_FROM_MARKDOWN);
252
253 return (tree: unknown, file: MarkdownFile) => {
254 transformCodexDirectives(tree as MarkdownAstNode, String(file.value));
255 };
256}
257
258export const remarkCodexDirectives = attachCodexDirectives;
259
260const directiveParser = unified().use(remarkParse).use(remarkCodexDirectives).freeze();
261
262function parseCodexMarkdown(markdown: string): MarkdownAstNode {
263 return directiveParser.runSync(directiveParser.parse(markdown), {
264 value: markdown,
265 }) as MarkdownAstNode;
266}
267
268interface DirectiveMatch {
269 readonly start: number;
270 readonly end: number;
271 readonly markdown?: string;
272 readonly template?: CodexArtifactTemplate;
273}
274
275function collectDirectiveMatches(node: MarkdownAstNode, matches: DirectiveMatch[]): void {
276 const start = node.position?.start.offset;
277 const end = node.position?.end.offset;
278 if (start !== undefined && end !== undefined) {
279 if (node.data?.codexFileCitationMarkdown !== undefined) {
280 matches.push({ start, end, markdown: node.data.codexFileCitationMarkdown });
281 return;
282 }
283 if (node.data?.codexArtifactTemplate !== undefined) {
284 matches.push({ start, end, template: node.data.codexArtifactTemplate });
285 return;
286 }
287 }
288 for (const child of node.children ?? []) collectDirectiveMatches(child, matches);
289}
290
291function renderDirectiveMatches(
292 markdown: string,
293 replacementFor: (match: DirectiveMatch) => string | undefined,
294): string {
295 const matches: DirectiveMatch[] = [];
296 collectDirectiveMatches(parseCodexMarkdown(markdown), matches);
297 let rendered = markdown;
298 for (const match of matches.sort((left, right) => right.start - left.start)) {
299 const replacement = replacementFor(match);
300 if (replacement !== undefined) {
301 rendered = rendered.slice(0, match.start) + replacement + rendered.slice(match.end);
302 }
303 }
304 return rendered;
305}
306
307/** Native Markdown renderers use this adapter because they cannot consume a Remark tree. */
308export function renderCodexFileCitationsAsMarkdown(markdown: string): string {
309 if (!markdown.includes(`:${CODEX_FILE_CITATION_NAME}`)) return markdown;
310
311 return renderDirectiveMatches(markdown, (match) => match.markdown);
312}
313
314/** Matches the Markdown emitted when users copy rendered Codex directive UI. */
315export function renderCodexDirectivesForCopy(markdown: string): string {
316 if (
317 !markdown.includes(`:${CODEX_FILE_CITATION_NAME}`) &&
318 !markdown.includes(`::${CODEX_ARTIFACT_TEMPLATE_NAME}`)
319 ) {
320 return markdown;
321 }
322
323 return renderDirectiveMatches(markdown, (match) => {
324 if (match.markdown !== undefined) return match.markdown;
325 if (match.template === undefined) return undefined;
326 return `${match.template.displayName} (${codexArtifactTemplatePresentationLabel(match.template.artifactKind)})`;
327 });
328}
329
330/** Native renderers split cards out because they cannot host a view inside Markdown text. */
331export function splitCodexArtifactTemplateMarkdown(
332 markdown: string,
333): ReadonlyArray<CodexArtifactTemplateMarkdownSegment> {
334 if (!markdown.includes(`::${CODEX_ARTIFACT_TEMPLATE_NAME}`)) {
335 return [{ kind: "markdown", markdown, sourceOffset: 0 }];
336 }
337
338 const matches: DirectiveMatch[] = [];
339 collectDirectiveMatches(parseCodexMarkdown(markdown), matches);
340 const templates = matches
341 .filter(
342 (match): match is DirectiveMatch & { readonly template: CodexArtifactTemplate } =>
343 match.template !== undefined,
344 )
345 .sort((left, right) => left.start - right.start);
346 if (templates.length === 0) {
347 return [{ kind: "markdown", markdown, sourceOffset: 0 }];
348 }
349
350 const segments: CodexArtifactTemplateMarkdownSegment[] = [];
351 let cursor = 0;
352 for (const match of templates) {
353 if (match.start > cursor) {
354 segments.push({
355 kind: "markdown",
356 markdown: markdown.slice(cursor, match.start),
357 sourceOffset: cursor,
358 });
359 }
360 segments.push({
361 kind: "artifact-template",
362 sourceOffset: match.start,
363 template: match.template,
364 });
365 cursor = match.end;
366 }
367 if (cursor < markdown.length) {
368 segments.push({ kind: "markdown", markdown: markdown.slice(cursor), sourceOffset: cursor });
369 }
370 return segments;
371}
372
373export function artifactTemplateFromHastProperties(
374 properties: Readonly<Record<string, unknown>> | null | undefined,
375): CodexArtifactTemplate | null {
376 if (properties?.dataCodexArtifactTemplate !== "true") return null;
377 const stringProperty = (name: string) => {
378 const value = properties[name];
379 return typeof value === "string" ? value : undefined;
380 };
381 return resolveCodexArtifactTemplate({
382 artifact_kind: stringProperty("dataArtifactKind"),
383 display_name: stringProperty("dataDisplayName"),
384 gallery_kind: stringProperty("dataGalleryKind"),
385 skill_directory: stringProperty("dataSkillDirectory"),
386 skill_name: stringProperty("dataSkillName"),
387 });
388}
pnpm-lock.yaml+48
......@@ -784,10 +784,28 @@ importers:
784784 effect:
785785 specifier: 4.0.0-beta.103
786786 version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)
787 mdast-util-directive:
788 specifier: ^3.1.0
789 version: 3.1.0
790 micromark-extension-directive:
791 specifier: ^4.0.0
792 version: 4.0.0
793 micromark-util-character:
794 specifier: ^2.1.1
795 version: 2.1.1
796 remark-parse:
797 specifier: ^11.0.0
798 version: 11.0.0
799 unified:
800 specifier: ^11.0.5
801 version: 11.0.5
787802 devDependencies:
788803 '@effect/vitest':
789804 specifier: 4.0.0-beta.103
790805 version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))
806 micromark-util-types:
807 specifier: ^2.0.2
808 version: 2.0.2
791809 vite-plus:
792810 specifier: 'catalog:'
793811 version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
......@@ -7726,6 +7744,9 @@ packages:
77267744 mdast-util-definitions@6.0.0:
77277745 resolution: {integrity: sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ==}
77287746
7747 mdast-util-directive@3.1.0:
7748 resolution: {integrity: sha512-I3fNFt+DHmpWCYAT7quoM6lHf9wuqtI+oCOfvILnoicNIqjh5E3dEJWiXuYME2gNe8vl1iMQwyUHa7bgFmak6Q==}
7749
77297750 mdast-util-find-and-replace@3.0.2:
77307751 resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==}
77317752
......@@ -7923,6 +7944,9 @@ packages:
79237944 micromark-core-commonmark@2.0.3:
79247945 resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==}
79257946
7947 micromark-extension-directive@4.0.0:
7948 resolution: {integrity: sha512-/C2nqVmXXmiseSSuCdItCMho7ybwwop6RrrRPk0KbOHW21JKoCldC+8rFOaundDoRBUWBnJJcxeA/Kvi34WQXg==}
7949
79267950 micromark-extension-gfm-autolink-literal@2.1.0:
79277951 resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==}
79287952
......@@ -17805,6 +17829,20 @@ snapshots:
1780517829 unist-util-visit: 5.1.0
1780617830 optional: true
1780717831
17832 mdast-util-directive@3.1.0:
17833 dependencies:
17834 '@types/mdast': 4.0.4
17835 '@types/unist': 3.0.3
17836 ccount: 2.0.1
17837 devlop: 1.1.0
17838 mdast-util-from-markdown: 2.0.3
17839 mdast-util-to-markdown: 2.1.2
17840 parse-entities: 4.0.2
17841 stringify-entities: 4.0.4
17842 unist-util-visit-parents: 6.0.2
17843 transitivePeerDependencies:
17844 - supports-color
17845
1780817846 mdast-util-find-and-replace@3.0.2:
1780917847 dependencies:
1781017848 '@types/mdast': 4.0.4
......@@ -18347,6 +18385,16 @@ snapshots:
1834718385 micromark-util-symbol: 2.0.1
1834818386 micromark-util-types: 2.0.2
1834918387
18388 micromark-extension-directive@4.0.0:
18389 dependencies:
18390 devlop: 1.1.0
18391 micromark-factory-space: 2.0.1
18392 micromark-factory-whitespace: 2.0.1
18393 micromark-util-character: 2.1.1
18394 micromark-util-symbol: 2.0.1
18395 micromark-util-types: 2.0.2
18396 parse-entities: 4.0.2
18397
1835018398 micromark-extension-gfm-autolink-literal@2.1.0:
1835118399 dependencies:
1835218400 micromark-util-character: 2.1.1