| author | |
| committer | |
| log | 0796638512d4c610469905adc597cc33c307fc32 |
| tree | eeb0e19a37b3493062b1be82989a86b4b533ba74 |
| parent | 7c43155883dcfe67340611ec68719aaeb3a5f8ff |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>6 files changed, 122 insertions(+), 18 deletions(-)
apps/mobile/src/features/threads/ThreadFeed.tsx+2-2| ... | ... | @@ -55,7 +55,7 @@ import { |
| 55 | 55 | } from "@t3tools/client-runtime/markdown-images"; |
| 56 | 56 | import { resolveViewedImageAsset } from "@t3tools/client-runtime/work-log/presentation"; |
| 57 | 57 | import { |
| 58 | renderCodexFileCitationsAsMarkdown, | |
| 58 | renderCodexInlineDirectivesAsMarkdown, | |
| 59 | 59 | splitCodexArtifactTemplateMarkdown, |
| 60 | 60 | } from "@t3tools/shared/codexMarkdownDirectives"; |
| 61 | 61 | import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; |
| ... | ... | @@ -837,7 +837,7 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { |
| 837 | 837 | } |
| 838 | 838 | if (segment.markdown.trim().length === 0) return null; |
| 839 | 839 | |
| 840 | const markdown = renderCodexFileCitationsAsMarkdown(segment.markdown); | |
| 840 | const markdown = renderCodexInlineDirectivesAsMarkdown(segment.markdown); | |
| 841 | 841 | return ( |
| 842 | 842 | <SelectableMarkdownText |
| 843 | 843 | key={`markdown:${segment.sourceOffset}`} |
apps/web/src/components/ChatMarkdown.test.tsx+30| ... | ... | @@ -791,6 +791,36 @@ describe("ChatMarkdown file option chips", () => { |
| 791 | 791 | }); |
| 792 | 792 | }); |
| 793 | 793 | |
| 794 | describe("ChatMarkdown Codex follow-up suggestions", () => { | |
| 795 | it.each([true, false])("renders the label with parseRawHtml=%s", (parseRawHtml) => { | |
| 796 | const html = renderToStaticMarkup( | |
| 797 | <ChatMarkdown | |
| 798 | cwd="/tmp/project" | |
| 799 | text={ | |
| 800 | 'Done.\n\n- :codex-followup[Prepare print version]{prompt="Prepare the document for printing."}' | |
| 801 | } | |
| 802 | lineBreaks={!parseRawHtml} | |
| 803 | parseRawHtml={parseRawHtml} | |
| 804 | />, | |
| 805 | ); | |
| 806 | ||
| 807 | expect(html).toContain("Prepare print version"); | |
| 808 | expect(html).not.toContain("codex-followup"); | |
| 809 | expect(html).not.toContain("Prepare the document for printing."); | |
| 810 | }); | |
| 811 | ||
| 812 | it("keeps the label of a malformed follow-up created by over-indented list recovery", () => { | |
| 813 | const html = renderToStaticMarkup( | |
| 814 | <ChatMarkdown | |
| 815 | cwd="/tmp/project" | |
| 816 | text="- Next :codex-followup[Prepare print version]" | |
| 817 | />, | |
| 818 | ); | |
| 819 | ||
| 820 | expect(html).toContain("<li>Next :codex-followup[Prepare print version]</li>"); | |
| 821 | }); | |
| 822 | }); | |
| 823 | ||
| 794 | 824 | const ARTIFACT_TEMPLATE_DIRECTIVE = |
| 795 | 825 | '::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"}'; |
| 796 | 826 |
apps/web/src/components/ChatMarkdown.tsx+2-2| ... | ... | @@ -110,7 +110,7 @@ import { MarkdownThreadLink } from "./chat/MarkdownThreadLink"; |
| 110 | 110 | import { isWindowsAbsolutePath } from "@t3tools/shared/path"; |
| 111 | 111 | import { |
| 112 | 112 | artifactTemplateFromHastProperties, |
| 113 | renderCodexFileCitationsAsMarkdown, | |
| 113 | renderCodexInlineDirectivesAsMarkdown, | |
| 114 | 114 | } from "@t3tools/shared/codexMarkdownDirectives"; |
| 115 | 115 | import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText"; |
| 116 | 116 | import { |
| ... | ... | @@ -2453,7 +2453,7 @@ function useChatMarkdownState({ |
| 2453 | 2453 | string, |
| 2454 | 2454 | NonNullable<ReturnType<typeof resolveMarkdownFileLinkMeta>> |
| 2455 | 2455 | >(); |
| 2456 | for (const href of extractMarkdownLinkHrefs(renderCodexFileCitationsAsMarkdown(text))) { | |
| 2456 | for (const href of extractMarkdownLinkHrefs(renderCodexInlineDirectivesAsMarkdown(text))) { | |
| 2457 | 2457 | if (parseComposerContextHref(href)) continue; |
| 2458 | 2458 | const normalizedHref = normalizeMarkdownLinkHrefKey(href); |
| 2459 | 2459 | if (metaByHref.has(normalizedHref)) continue; |
packages/shared/src/codexMarkdownDirectives.test.ts+40-6| ... | ... | @@ -5,7 +5,7 @@ import { unified } from "unified"; |
| 5 | 5 | import { |
| 6 | 6 | remarkCodexDirectives, |
| 7 | 7 | renderCodexDirectivesForCopy, |
| 8 | renderCodexFileCitationsAsMarkdown, | |
| 8 | renderCodexInlineDirectivesAsMarkdown, | |
| 9 | 9 | splitCodexArtifactTemplateMarkdown, |
| 10 | 10 | } from "./codexMarkdownDirectives.ts"; |
| 11 | 11 | import { parseMarkdownFileLink } from "./markdownLinks.ts"; |
| ... | ... | @@ -26,6 +26,8 @@ interface TestNode { |
| 26 | 26 | } |
| 27 | 27 | |
| 28 | 28 | const FILE_CITATION = ':codex-file-citation{path="outputs/report.xlsx" purpose="output"}'; |
| 29 | const FOLLOW_UP = | |
| 30 | ':codex-followup[Prepare print version]{prompt="Prepare the document for printing."}'; | |
| 29 | 31 | const ARTIFACT_TEMPLATE = |
| 30 | 32 | '::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"}'; |
| 31 | 33 | |
| ... | ... | @@ -70,6 +72,15 @@ describe("remarkCodexDirectives", () => { |
| 70 | 72 | }); |
| 71 | 73 | }); |
| 72 | 74 | |
| 75 | it("renders a follow-up suggestion as its label", () => { | |
| 76 | const followUp = parse(`Next: ${FOLLOW_UP}`).children?.[0]?.children?.[1]; | |
| 77 | ||
| 78 | expect(followUp).toMatchObject({ | |
| 79 | data: { hName: "span" }, | |
| 80 | children: [{ type: "text", value: "Prepare print version" }], | |
| 81 | }); | |
| 82 | }); | |
| 83 | ||
| 73 | 84 | it.each([ |
| 74 | 85 | "Meeting at 10:30", |
| 75 | 86 | "Open src/main.ts:42", |
| ... | ... | @@ -78,6 +89,7 @@ describe("remarkCodexDirectives", () => { |
| 78 | 89 | ":::note\ncontent\n:::", |
| 79 | 90 | ':codex-file-citation-extra{path="outputs/report.xlsx"}', |
| 80 | 91 | "::artifact-template-extra", |
| 92 | ':codex-followup-extra[Label]{prompt="Prompt"}', | |
| 81 | 93 | ])("does not change unrelated colon syntax: %s", (markdown) => { |
| 82 | 94 | expect(parse(markdown)).toEqual(parseOrdinaryMarkdown(markdown)); |
| 83 | 95 | }); |
| ... | ... | @@ -85,6 +97,9 @@ describe("remarkCodexDirectives", () => { |
| 85 | 97 | it.each([ |
| 86 | 98 | ':codex-file-citation{purpose="output"}', |
| 87 | 99 | '::artifact-template{skill_name="artifact-template-hello-world"}', |
| 100 | ":codex-followup[Prepare print version]", | |
| 101 | ':codex-followup[ ]{prompt="Prepare the document for printing."}', | |
| 102 | ':codex-followup[Prepare print version]{prompt=" "}', | |
| 88 | 103 | ])("keeps malformed supported directives literal: %s", (markdown) => { |
| 89 | 104 | expect(parse(markdown)).toEqual(parseOrdinaryMarkdown(markdown)); |
| 90 | 105 | }); |
| ... | ... | @@ -92,7 +107,7 @@ describe("remarkCodexDirectives", () => { |
| 92 | 107 | |
| 93 | 108 | describe.each([ |
| 94 | 109 | { name: "renderCodexDirectivesForCopy", render: renderCodexDirectivesForCopy }, |
| 95 | { name: "renderCodexFileCitationsAsMarkdown", render: renderCodexFileCitationsAsMarkdown }, | |
| 110 | { name: "renderCodexInlineDirectivesAsMarkdown", render: renderCodexInlineDirectivesAsMarkdown }, | |
| 96 | 111 | ])("$name file citation round trips", ({ render }) => { |
| 97 | 112 | it.each([ |
| 98 | 113 | "C:\\Users\\test\\[draft]\\report.md", |
| ... | ... | @@ -110,18 +125,33 @@ describe.each([ |
| 110 | 125 | |
| 111 | 126 | describe("native Markdown adapters", () => { |
| 112 | 127 | it("uses the same parser to render file citations as portable links", () => { |
| 113 | expect(renderCodexFileCitationsAsMarkdown(`Created ${FILE_CITATION}.`)).toBe( | |
| 128 | expect(renderCodexInlineDirectivesAsMarkdown(`Created ${FILE_CITATION}.`)).toBe( | |
| 114 | 129 | "Created [report.xlsx](<outputs/report.xlsx>).", |
| 115 | 130 | ); |
| 116 | 131 | }); |
| 117 | 132 | |
| 133 | it("renders follow-up suggestions as their label Markdown", () => { | |
| 134 | expect( | |
| 135 | renderCodexInlineDirectivesAsMarkdown( | |
| 136 | `- ${FOLLOW_UP}\n- :codex-followup[Review **document**]{prompt="Review it."}`, | |
| 137 | ), | |
| 138 | ).toBe("- Prepare print version\n- Review **document**"); | |
| 139 | }); | |
| 140 | ||
| 141 | it.each([`\\${FOLLOW_UP}`, `\`${FOLLOW_UP}\``])( | |
| 142 | "does not render excluded follow-up syntax: %s", | |
| 143 | (markdown) => { | |
| 144 | expect(renderCodexInlineDirectivesAsMarkdown(markdown)).toBe(markdown); | |
| 145 | }, | |
| 146 | ); | |
| 147 | ||
| 118 | 148 | it.each([ |
| 119 | 149 | `\\${FILE_CITATION}`, |
| 120 | 150 | `\`${FILE_CITATION}\``, |
| 121 | 151 | `\`\`\`text\n${FILE_CITATION}\n\`\`\``, |
| 122 | 152 | `[See ${FILE_CITATION}](https://example.com)`, |
| 123 | 153 | ])("does not render excluded citation syntax: %s", (markdown) => { |
| 124 | expect(renderCodexFileCitationsAsMarkdown(markdown)).toBe(markdown); | |
| 154 | expect(renderCodexInlineDirectivesAsMarkdown(markdown)).toBe(markdown); | |
| 125 | 155 | }); |
| 126 | 156 | |
| 127 | 157 | it("splits artifact cards from surrounding native Markdown", () => { |
| ... | ... | @@ -159,8 +189,12 @@ describe("native Markdown adapters", () => { |
| 159 | 189 | |
| 160 | 190 | describe("directive copy adapter", () => { |
| 161 | 191 | it("copies the Markdown representations shown by citation chips and template cards", () => { |
| 162 | expect(renderCodexDirectivesForCopy(`Created ${FILE_CITATION}.\n\n${ARTIFACT_TEMPLATE}`)).toBe( | |
| 163 | "Created [report.xlsx](<outputs/report.xlsx>).\n\nHello World (Document template)", | |
| 192 | expect( | |
| 193 | renderCodexDirectivesForCopy( | |
| 194 | `Created ${FILE_CITATION}.\n\n${ARTIFACT_TEMPLATE}\n\n- ${FOLLOW_UP}`, | |
| 195 | ), | |
| 196 | ).toBe( | |
| 197 | "Created [report.xlsx](<outputs/report.xlsx>).\n\nHello World (Document template)\n\n- Prepare print version", | |
| 164 | 198 | ); |
| 165 | 199 | }); |
| 166 | 200 |
packages/shared/src/codexMarkdownDirectives.ts+46-6| ... | ... | @@ -20,6 +20,7 @@ const COLON = 58; |
| 20 | 20 | const DASH = 45; |
| 21 | 21 | const UNDERSCORE = 95; |
| 22 | 22 | const CODEX_FILE_CITATION_NAME = "codex-file-citation"; |
| 23 | const CODEX_FOLLOW_UP_NAME = "codex-followup"; | |
| 23 | 24 | const CODEX_ARTIFACT_TEMPLATE_NAME = "artifact-template"; |
| 24 | 25 | |
| 25 | 26 | export const CODEX_ARTIFACT_TEMPLATE_HAST_PROPERTIES = [ |
| ... | ... | @@ -46,6 +47,7 @@ interface MarkdownAstNode { |
| 46 | 47 | data?: { |
| 47 | 48 | codexArtifactTemplate?: CodexArtifactTemplate; |
| 48 | 49 | codexFileCitationMarkdown?: string; |
| 50 | codexFollowUp?: true; | |
| 49 | 51 | hName?: string; |
| 50 | 52 | hProperties?: Record<string, unknown>; |
| 51 | 53 | }; |
| ... | ... | @@ -132,7 +134,10 @@ function codexDirectiveSyntax(): Extension { |
| 132 | 134 | |
| 133 | 135 | return { |
| 134 | 136 | text: { |
| 135 | [COLON]: restrictedDirective(textDirective, 1, CODEX_FILE_CITATION_NAME), | |
| 137 | [COLON]: [ | |
| 138 | restrictedDirective(textDirective, 1, CODEX_FILE_CITATION_NAME), | |
| 139 | restrictedDirective(textDirective, 1, CODEX_FOLLOW_UP_NAME), | |
| 140 | ], | |
| 136 | 141 | }, |
| 137 | 142 | flow: { |
| 138 | 143 | [COLON]: restrictedDirective(leafDirective, 2, CODEX_ARTIFACT_TEMPLATE_NAME), |
| ... | ... | @@ -149,15 +154,20 @@ function sourceForNode(node: MarkdownAstNode, source: string): string { |
| 149 | 154 | return start === undefined || end === undefined ? "" : source.slice(start, end); |
| 150 | 155 | } |
| 151 | 156 | |
| 157 | function textContent(node: MarkdownAstNode): string { | |
| 158 | return node.value ?? (node.children ?? []).map(textContent).join(""); | |
| 159 | } | |
| 160 | ||
| 152 | 161 | function sourceForDirective(node: MarkdownAstNode, source: string, marker: ":" | "::"): string { |
| 153 | 162 | const prefix = `${marker}${node.name ?? ""}`; |
| 154 | 163 | const slicedSource = sourceForNode(node, source); |
| 155 | 164 | if (slicedSource.startsWith(prefix)) return slicedSource; |
| 156 | 165 | |
| 166 | const label = node.children?.length ? `[${textContent(node)}]` : ""; | |
| 157 | 167 | const attributes = Object.entries(node.attributes ?? {}).map(([name, value]) => |
| 158 | 168 | value === null ? name : `${name}=${JSON.stringify(value)}`, |
| 159 | 169 | ); |
| 160 | return `${prefix}${attributes.length === 0 ? "" : `{${attributes.join(" ")}}`}`; | |
| 170 | return `${prefix}${label}${attributes.length === 0 ? "" : `{${attributes.join(" ")}}`}`; | |
| 161 | 171 | } |
| 162 | 172 | |
| 163 | 173 | function restoreTextDirective(node: MarkdownAstNode, source: string): void { |
| ... | ... | @@ -199,6 +209,18 @@ function renderFileCitation(node: MarkdownAstNode, source: string, insideLink: b |
| 199 | 209 | delete node.value; |
| 200 | 210 | } |
| 201 | 211 | |
| 212 | /** Shows only the label. T3 has no suggestion UI, so the hidden prompt is not shown. */ | |
| 213 | function renderFollowUp(node: MarkdownAstNode, source: string): void { | |
| 214 | if (!node.attributes?.prompt?.trim() || !textContent(node).trim()) { | |
| 215 | restoreTextDirective(node, source); | |
| 216 | return; | |
| 217 | } | |
| 218 | ||
| 219 | node.data = { codexFollowUp: true, hName: "span" }; | |
| 220 | delete node.name; | |
| 221 | delete node.attributes; | |
| 222 | } | |
| 223 | ||
| 202 | 224 | function renderArtifactTemplate(node: MarkdownAstNode, source: string): void { |
| 203 | 225 | const template = resolveCodexArtifactTemplate(node.attributes); |
| 204 | 226 | if (!template) { |
| ... | ... | @@ -231,6 +253,9 @@ function transformCodexDirectives(node: MarkdownAstNode, source: string, insideL |
| 231 | 253 | renderFileCitation(node, source, insideLink); |
| 232 | 254 | return; |
| 233 | 255 | } |
| 256 | if (node.type === "textDirective" && node.name === CODEX_FOLLOW_UP_NAME) { | |
| 257 | renderFollowUp(node, source); | |
| 258 | } | |
| 234 | 259 | if (node.type === "leafDirective" && node.name === CODEX_ARTIFACT_TEMPLATE_NAME) { |
| 235 | 260 | renderArtifactTemplate(node, source); |
| 236 | 261 | return; |
| ... | ... | @@ -242,7 +267,7 @@ function transformCodexDirectives(node: MarkdownAstNode, source: string, insideL |
| 242 | 267 | } |
| 243 | 268 | } |
| 244 | 269 | |
| 245 | /** Adds grammar only for the two directives emitted by Codex, then renders them as mdast. */ | |
| 270 | /** Adds grammar only for the directives emitted by Codex, then renders them as mdast. */ | |
| 246 | 271 | function attachCodexDirectives(this: Processor) { |
| 247 | 272 | const data = this.data(); |
| 248 | 273 | const micromarkExtensions = data.micromarkExtensions ?? (data.micromarkExtensions = []); |
| ... | ... | @@ -270,6 +295,7 @@ interface DirectiveMatch { |
| 270 | 295 | readonly end: number; |
| 271 | 296 | readonly markdown?: string; |
| 272 | 297 | readonly template?: CodexArtifactTemplate; |
| 298 | readonly labelRange?: readonly [start: number, end: number]; | |
| 273 | 299 | } |
| 274 | 300 | |
| 275 | 301 | function collectDirectiveMatches(node: MarkdownAstNode, matches: DirectiveMatch[]): void { |
| ... | ... | @@ -284,6 +310,12 @@ function collectDirectiveMatches(node: MarkdownAstNode, matches: DirectiveMatch[ |
| 284 | 310 | matches.push({ start, end, template: node.data.codexArtifactTemplate }); |
| 285 | 311 | return; |
| 286 | 312 | } |
| 313 | const labelStart = node.children?.[0]?.position?.start.offset; | |
| 314 | const labelEnd = node.children?.at(-1)?.position?.end.offset; | |
| 315 | if (node.data?.codexFollowUp && labelStart !== undefined && labelEnd !== undefined) { | |
| 316 | matches.push({ start, end, labelRange: [labelStart, labelEnd] }); | |
| 317 | return; | |
| 318 | } | |
| 287 | 319 | } |
| 288 | 320 | for (const child of node.children ?? []) collectDirectiveMatches(child, matches); |
| 289 | 321 | } |
| ... | ... | @@ -296,7 +328,9 @@ function renderDirectiveMatches( |
| 296 | 328 | collectDirectiveMatches(parseCodexMarkdown(markdown), matches); |
| 297 | 329 | let rendered = markdown; |
| 298 | 330 | for (const match of matches.sort((left, right) => right.start - left.start)) { |
| 299 | const replacement = replacementFor(match); | |
| 331 | const replacement = match.labelRange | |
| 332 | ? markdown.slice(...match.labelRange) | |
| 333 | : replacementFor(match); | |
| 300 | 334 | if (replacement !== undefined) { |
| 301 | 335 | rendered = rendered.slice(0, match.start) + replacement + rendered.slice(match.end); |
| 302 | 336 | } |
| ... | ... | @@ -305,8 +339,13 @@ function renderDirectiveMatches( |
| 305 | 339 | } |
| 306 | 340 | |
| 307 | 341 | /** Native Markdown renderers use this adapter because they cannot consume a Remark tree. */ |
| 308 | export function renderCodexFileCitationsAsMarkdown(markdown: string): string { | |
| 309 | if (!markdown.includes(`:${CODEX_FILE_CITATION_NAME}`)) return markdown; | |
| 342 | export function renderCodexInlineDirectivesAsMarkdown(markdown: string): string { | |
| 343 | if ( | |
| 344 | !markdown.includes(`:${CODEX_FILE_CITATION_NAME}`) && | |
| 345 | !markdown.includes(`:${CODEX_FOLLOW_UP_NAME}`) | |
| 346 | ) { | |
| 347 | return markdown; | |
| 348 | } | |
| 310 | 349 | |
| 311 | 350 | return renderDirectiveMatches(markdown, (match) => match.markdown); |
| 312 | 351 | } |
| ... | ... | @@ -315,6 +354,7 @@ export function renderCodexFileCitationsAsMarkdown(markdown: string): string { |
| 315 | 354 | export function renderCodexDirectivesForCopy(markdown: string): string { |
| 316 | 355 | if ( |
| 317 | 356 | !markdown.includes(`:${CODEX_FILE_CITATION_NAME}`) && |
| 357 | !markdown.includes(`:${CODEX_FOLLOW_UP_NAME}`) && | |
| 318 | 358 | !markdown.includes(`::${CODEX_ARTIFACT_TEMPLATE_NAME}`) |
| 319 | 359 | ) { |
| 320 | 360 | return markdown; |
packages/shared/src/threadFindText.ts+2-2| ... | ... | @@ -5,7 +5,7 @@ import { |
| 5 | 5 | } from "./inlineSkills.ts"; |
| 6 | 6 | import { |
| 7 | 7 | artifactTemplateFromHastProperties, |
| 8 | renderCodexFileCitationsAsMarkdown, | |
| 8 | renderCodexInlineDirectivesAsMarkdown, | |
| 9 | 9 | } from "./codexMarkdownDirectives.ts"; |
| 10 | 10 | import { codexArtifactTemplatePresentationLabel } from "./codexArtifactTemplates.ts"; |
| 11 | 11 | import { |
| ... | ... | @@ -117,7 +117,7 @@ function markdownThreadFindText( |
| 117 | 117 | : assistantProcessor; |
| 118 | 118 | const tree = processor.runSync(processor.parse(markdown)); |
| 119 | 119 | const paths = [ |
| 120 | ...extractMarkdownLinkHrefs(renderCodexFileCitationsAsMarkdown(markdown)), | |
| 120 | ...extractMarkdownLinkHrefs(renderCodexInlineDirectivesAsMarkdown(markdown)), | |
| 121 | 121 | ...extractInlineCodeSpans(markdown).flatMap((span) => inlineCodeFilePathCandidate(span) ?? []), |
| 122 | 122 | ].flatMap((href) => { |
| 123 | 123 | const target = resolveMarkdownFileLinkTarget(href, cwd); |