diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 4a1e9d1a1f5d635496a7563f7510536d436cd788..5ab042dba12502d975e960c0cc64849108d63ff2 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -55,7 +55,7 @@ import { } from "@t3tools/client-runtime/markdown-images"; import { resolveViewedImageAsset } from "@t3tools/client-runtime/work-log/presentation"; import { - renderCodexFileCitationsAsMarkdown, + renderCodexInlineDirectivesAsMarkdown, splitCodexArtifactTemplateMarkdown, } from "@t3tools/shared/codexMarkdownDirectives"; import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; @@ -837,7 +837,7 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { } if (segment.markdown.trim().length === 0) return null; - const markdown = renderCodexFileCitationsAsMarkdown(segment.markdown); + const markdown = renderCodexInlineDirectivesAsMarkdown(segment.markdown); return ( { }); }); +describe("ChatMarkdown Codex follow-up suggestions", () => { + it.each([true, false])("renders the label with parseRawHtml=%s", (parseRawHtml) => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain("Prepare print version"); + expect(html).not.toContain("codex-followup"); + expect(html).not.toContain("Prepare the document for printing."); + }); + + it("keeps the label of a malformed follow-up created by over-indented list recovery", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain("
  • Next :codex-followup[Prepare print version]
  • "); + }); +}); + const ARTIFACT_TEMPLATE_DIRECTIVE = '::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"}'; diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index e959f76a2593e6e3f8702ad94043e5ac02fe85de..821d1643ede09b1d2b22f25d5f382af82d8b2eed 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -110,7 +110,7 @@ import { MarkdownThreadLink } from "./chat/MarkdownThreadLink"; import { isWindowsAbsolutePath } from "@t3tools/shared/path"; import { artifactTemplateFromHastProperties, - renderCodexFileCitationsAsMarkdown, + renderCodexInlineDirectivesAsMarkdown, } from "@t3tools/shared/codexMarkdownDirectives"; import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText"; import { @@ -2453,7 +2453,7 @@ function useChatMarkdownState({ string, NonNullable> >(); - for (const href of extractMarkdownLinkHrefs(renderCodexFileCitationsAsMarkdown(text))) { + for (const href of extractMarkdownLinkHrefs(renderCodexInlineDirectivesAsMarkdown(text))) { if (parseComposerContextHref(href)) continue; const normalizedHref = normalizeMarkdownLinkHrefKey(href); if (metaByHref.has(normalizedHref)) continue; diff --git a/packages/shared/src/codexMarkdownDirectives.test.ts b/packages/shared/src/codexMarkdownDirectives.test.ts index 2cfd1a0e20bb8621a6685f96030d15ac02c2fdf1..2f41568ce007a6116911601f5b2807aebde35b58 100644 --- a/packages/shared/src/codexMarkdownDirectives.test.ts +++ b/packages/shared/src/codexMarkdownDirectives.test.ts @@ -5,7 +5,7 @@ import { unified } from "unified"; import { remarkCodexDirectives, renderCodexDirectivesForCopy, - renderCodexFileCitationsAsMarkdown, + renderCodexInlineDirectivesAsMarkdown, splitCodexArtifactTemplateMarkdown, } from "./codexMarkdownDirectives.ts"; import { parseMarkdownFileLink } from "./markdownLinks.ts"; @@ -26,6 +26,8 @@ interface TestNode { } const FILE_CITATION = ':codex-file-citation{path="outputs/report.xlsx" purpose="output"}'; +const FOLLOW_UP = + ':codex-followup[Prepare print version]{prompt="Prepare the document for printing."}'; const ARTIFACT_TEMPLATE = '::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"}'; @@ -70,6 +72,15 @@ describe("remarkCodexDirectives", () => { }); }); + it("renders a follow-up suggestion as its label", () => { + const followUp = parse(`Next: ${FOLLOW_UP}`).children?.[0]?.children?.[1]; + + expect(followUp).toMatchObject({ + data: { hName: "span" }, + children: [{ type: "text", value: "Prepare print version" }], + }); + }); + it.each([ "Meeting at 10:30", "Open src/main.ts:42", @@ -78,6 +89,7 @@ describe("remarkCodexDirectives", () => { ":::note\ncontent\n:::", ':codex-file-citation-extra{path="outputs/report.xlsx"}', "::artifact-template-extra", + ':codex-followup-extra[Label]{prompt="Prompt"}', ])("does not change unrelated colon syntax: %s", (markdown) => { expect(parse(markdown)).toEqual(parseOrdinaryMarkdown(markdown)); }); @@ -85,6 +97,9 @@ describe("remarkCodexDirectives", () => { it.each([ ':codex-file-citation{purpose="output"}', '::artifact-template{skill_name="artifact-template-hello-world"}', + ":codex-followup[Prepare print version]", + ':codex-followup[ ]{prompt="Prepare the document for printing."}', + ':codex-followup[Prepare print version]{prompt=" "}', ])("keeps malformed supported directives literal: %s", (markdown) => { expect(parse(markdown)).toEqual(parseOrdinaryMarkdown(markdown)); }); @@ -92,7 +107,7 @@ describe("remarkCodexDirectives", () => { describe.each([ { name: "renderCodexDirectivesForCopy", render: renderCodexDirectivesForCopy }, - { name: "renderCodexFileCitationsAsMarkdown", render: renderCodexFileCitationsAsMarkdown }, + { name: "renderCodexInlineDirectivesAsMarkdown", render: renderCodexInlineDirectivesAsMarkdown }, ])("$name file citation round trips", ({ render }) => { it.each([ "C:\\Users\\test\\[draft]\\report.md", @@ -110,18 +125,33 @@ describe.each([ describe("native Markdown adapters", () => { it("uses the same parser to render file citations as portable links", () => { - expect(renderCodexFileCitationsAsMarkdown(`Created ${FILE_CITATION}.`)).toBe( + expect(renderCodexInlineDirectivesAsMarkdown(`Created ${FILE_CITATION}.`)).toBe( "Created [report.xlsx]().", ); }); + it("renders follow-up suggestions as their label Markdown", () => { + expect( + renderCodexInlineDirectivesAsMarkdown( + `- ${FOLLOW_UP}\n- :codex-followup[Review **document**]{prompt="Review it."}`, + ), + ).toBe("- Prepare print version\n- Review **document**"); + }); + + it.each([`\\${FOLLOW_UP}`, `\`${FOLLOW_UP}\``])( + "does not render excluded follow-up syntax: %s", + (markdown) => { + expect(renderCodexInlineDirectivesAsMarkdown(markdown)).toBe(markdown); + }, + ); + it.each([ `\\${FILE_CITATION}`, `\`${FILE_CITATION}\``, `\`\`\`text\n${FILE_CITATION}\n\`\`\``, `[See ${FILE_CITATION}](https://example.com)`, ])("does not render excluded citation syntax: %s", (markdown) => { - expect(renderCodexFileCitationsAsMarkdown(markdown)).toBe(markdown); + expect(renderCodexInlineDirectivesAsMarkdown(markdown)).toBe(markdown); }); it("splits artifact cards from surrounding native Markdown", () => { @@ -159,8 +189,12 @@ describe("native Markdown adapters", () => { describe("directive copy adapter", () => { it("copies the Markdown representations shown by citation chips and template cards", () => { - expect(renderCodexDirectivesForCopy(`Created ${FILE_CITATION}.\n\n${ARTIFACT_TEMPLATE}`)).toBe( - "Created [report.xlsx]().\n\nHello World (Document template)", + expect( + renderCodexDirectivesForCopy( + `Created ${FILE_CITATION}.\n\n${ARTIFACT_TEMPLATE}\n\n- ${FOLLOW_UP}`, + ), + ).toBe( + "Created [report.xlsx]().\n\nHello World (Document template)\n\n- Prepare print version", ); }); diff --git a/packages/shared/src/codexMarkdownDirectives.ts b/packages/shared/src/codexMarkdownDirectives.ts index 64a981a6b796f4cfe679f7bb36d77cd8cbad250f..6a01aeb27b8c9a91e6bc2bb8d77aa7411b5506cd 100644 --- a/packages/shared/src/codexMarkdownDirectives.ts +++ b/packages/shared/src/codexMarkdownDirectives.ts @@ -20,6 +20,7 @@ const COLON = 58; const DASH = 45; const UNDERSCORE = 95; const CODEX_FILE_CITATION_NAME = "codex-file-citation"; +const CODEX_FOLLOW_UP_NAME = "codex-followup"; const CODEX_ARTIFACT_TEMPLATE_NAME = "artifact-template"; export const CODEX_ARTIFACT_TEMPLATE_HAST_PROPERTIES = [ @@ -46,6 +47,7 @@ interface MarkdownAstNode { data?: { codexArtifactTemplate?: CodexArtifactTemplate; codexFileCitationMarkdown?: string; + codexFollowUp?: true; hName?: string; hProperties?: Record; }; @@ -132,7 +134,10 @@ function codexDirectiveSyntax(): Extension { return { text: { - [COLON]: restrictedDirective(textDirective, 1, CODEX_FILE_CITATION_NAME), + [COLON]: [ + restrictedDirective(textDirective, 1, CODEX_FILE_CITATION_NAME), + restrictedDirective(textDirective, 1, CODEX_FOLLOW_UP_NAME), + ], }, flow: { [COLON]: restrictedDirective(leafDirective, 2, CODEX_ARTIFACT_TEMPLATE_NAME), @@ -149,15 +154,20 @@ function sourceForNode(node: MarkdownAstNode, source: string): string { return start === undefined || end === undefined ? "" : source.slice(start, end); } +function textContent(node: MarkdownAstNode): string { + return node.value ?? (node.children ?? []).map(textContent).join(""); +} + function sourceForDirective(node: MarkdownAstNode, source: string, marker: ":" | "::"): string { const prefix = `${marker}${node.name ?? ""}`; const slicedSource = sourceForNode(node, source); if (slicedSource.startsWith(prefix)) return slicedSource; + const label = node.children?.length ? `[${textContent(node)}]` : ""; const attributes = Object.entries(node.attributes ?? {}).map(([name, value]) => value === null ? name : `${name}=${JSON.stringify(value)}`, ); - return `${prefix}${attributes.length === 0 ? "" : `{${attributes.join(" ")}}`}`; + return `${prefix}${label}${attributes.length === 0 ? "" : `{${attributes.join(" ")}}`}`; } function restoreTextDirective(node: MarkdownAstNode, source: string): void { @@ -199,6 +209,18 @@ function renderFileCitation(node: MarkdownAstNode, source: string, insideLink: b delete node.value; } +/** Shows only the label. T3 has no suggestion UI, so the hidden prompt is not shown. */ +function renderFollowUp(node: MarkdownAstNode, source: string): void { + if (!node.attributes?.prompt?.trim() || !textContent(node).trim()) { + restoreTextDirective(node, source); + return; + } + + node.data = { codexFollowUp: true, hName: "span" }; + delete node.name; + delete node.attributes; +} + function renderArtifactTemplate(node: MarkdownAstNode, source: string): void { const template = resolveCodexArtifactTemplate(node.attributes); if (!template) { @@ -231,6 +253,9 @@ function transformCodexDirectives(node: MarkdownAstNode, source: string, insideL renderFileCitation(node, source, insideLink); return; } + if (node.type === "textDirective" && node.name === CODEX_FOLLOW_UP_NAME) { + renderFollowUp(node, source); + } if (node.type === "leafDirective" && node.name === CODEX_ARTIFACT_TEMPLATE_NAME) { renderArtifactTemplate(node, source); return; @@ -242,7 +267,7 @@ function transformCodexDirectives(node: MarkdownAstNode, source: string, insideL } } -/** Adds grammar only for the two directives emitted by Codex, then renders them as mdast. */ +/** Adds grammar only for the directives emitted by Codex, then renders them as mdast. */ function attachCodexDirectives(this: Processor) { const data = this.data(); const micromarkExtensions = data.micromarkExtensions ?? (data.micromarkExtensions = []); @@ -270,6 +295,7 @@ interface DirectiveMatch { readonly end: number; readonly markdown?: string; readonly template?: CodexArtifactTemplate; + readonly labelRange?: readonly [start: number, end: number]; } function collectDirectiveMatches(node: MarkdownAstNode, matches: DirectiveMatch[]): void { @@ -284,6 +310,12 @@ function collectDirectiveMatches(node: MarkdownAstNode, matches: DirectiveMatch[ matches.push({ start, end, template: node.data.codexArtifactTemplate }); return; } + const labelStart = node.children?.[0]?.position?.start.offset; + const labelEnd = node.children?.at(-1)?.position?.end.offset; + if (node.data?.codexFollowUp && labelStart !== undefined && labelEnd !== undefined) { + matches.push({ start, end, labelRange: [labelStart, labelEnd] }); + return; + } } for (const child of node.children ?? []) collectDirectiveMatches(child, matches); } @@ -296,7 +328,9 @@ function renderDirectiveMatches( collectDirectiveMatches(parseCodexMarkdown(markdown), matches); let rendered = markdown; for (const match of matches.sort((left, right) => right.start - left.start)) { - const replacement = replacementFor(match); + const replacement = match.labelRange + ? markdown.slice(...match.labelRange) + : replacementFor(match); if (replacement !== undefined) { rendered = rendered.slice(0, match.start) + replacement + rendered.slice(match.end); } @@ -305,8 +339,13 @@ function renderDirectiveMatches( } /** Native Markdown renderers use this adapter because they cannot consume a Remark tree. */ -export function renderCodexFileCitationsAsMarkdown(markdown: string): string { - if (!markdown.includes(`:${CODEX_FILE_CITATION_NAME}`)) return markdown; +export function renderCodexInlineDirectivesAsMarkdown(markdown: string): string { + if ( + !markdown.includes(`:${CODEX_FILE_CITATION_NAME}`) && + !markdown.includes(`:${CODEX_FOLLOW_UP_NAME}`) + ) { + return markdown; + } return renderDirectiveMatches(markdown, (match) => match.markdown); } @@ -315,6 +354,7 @@ export function renderCodexFileCitationsAsMarkdown(markdown: string): string { export function renderCodexDirectivesForCopy(markdown: string): string { if ( !markdown.includes(`:${CODEX_FILE_CITATION_NAME}`) && + !markdown.includes(`:${CODEX_FOLLOW_UP_NAME}`) && !markdown.includes(`::${CODEX_ARTIFACT_TEMPLATE_NAME}`) ) { return markdown; diff --git a/packages/shared/src/threadFindText.ts b/packages/shared/src/threadFindText.ts index ab293fa68ea44c42d6b7d7d21145f9ab39d711a3..d4019bf28cd428db5aebe5e8a5a429205125d1d6 100644 --- a/packages/shared/src/threadFindText.ts +++ b/packages/shared/src/threadFindText.ts @@ -5,7 +5,7 @@ import { } from "./inlineSkills.ts"; import { artifactTemplateFromHastProperties, - renderCodexFileCitationsAsMarkdown, + renderCodexInlineDirectivesAsMarkdown, } from "./codexMarkdownDirectives.ts"; import { codexArtifactTemplatePresentationLabel } from "./codexArtifactTemplates.ts"; import { @@ -117,7 +117,7 @@ function markdownThreadFindText( : assistantProcessor; const tree = processor.runSync(processor.parse(markdown)); const paths = [ - ...extractMarkdownLinkHrefs(renderCodexFileCitationsAsMarkdown(markdown)), + ...extractMarkdownLinkHrefs(renderCodexInlineDirectivesAsMarkdown(markdown)), ...extractInlineCodeSpans(markdown).flatMap((span) => inlineCodeFilePathCandidate(span) ?? []), ].flatMap((href) => { const target = resolveMarkdownFileLinkTarget(href, cwd);