1import {
2 matchInlineSkills,
3 formatProviderSkillDisplayName,
4 type InlineSkill,
5} from "./inlineSkills.ts";
6import {
7 artifactTemplateFromHastProperties,
8 renderCodexInlineDirectivesAsMarkdown,
9} from "./codexMarkdownDirectives.ts";
10import { codexArtifactTemplatePresentationLabel } from "./codexArtifactTemplates.ts";
11import {
12 buildFileLinkParentSuffixByPath,
13 splitFilePathPosition,
14 fileLinkLabel,
15} from "./fileLinks.ts";
16import {
17 isMarkdownFileLinkLabel,
18 extractMarkdownLinkHrefs,
19 extractInlineCodeSpans,
20 inlineCodeFilePathCandidate,
21 resolveMarkdownFileLinkTarget,
22} from "./markdownLinks.ts";
23import { upgradeLegacyContextMessage } from "./composerContextLegacy.ts";
24import { parseComposerContextHref } from "./composerContextReferences.ts";
25import { assistantCitationLabel, parseAssistantCitationHref } from "./assistantCitations.ts";
26import type { OrchestrationV2ConversationMessage } from "@t3tools/contracts";
27import { proposedPlanTitle, stripDisplayedPlanMarkdown } from "./proposedPlanText.ts";
28import { unified } from "unified";
29import remarkParse from "remark-parse";
30import remarkRehype from "remark-rehype";
31import {
32 shouldPreserveAssistantLineBreaks,
33 CHAT_MARKDOWN_REHYPE_PLUGINS,
34 CHAT_MARKDOWN_REMARK_PLUGINS,
35 CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS,
36} from "./markdownPipeline.ts";
37
38// Inline wrappers (including Shiki token spans) must not split a search phrase.
39export const THREAD_FIND_BLOCK_TAGS = new Set([
40 "address",
41 "article",
42 "aside",
43 "blockquote",
44 "br",
45 "dd",
46 "details",
47 "div",
48 "dl",
49 "dt",
50 "figcaption",
51 "figure",
52 "footer",
53 "h1",
54 "h2",
55 "h3",
56 "h4",
57 "h5",
58 "h6",
59 "header",
60 "hr",
61 "li",
62 "main",
63 "nav",
64 "ol",
65 "p",
66 "pre",
67 "section",
68 "summary",
69 "table",
70 "tbody",
71 "td",
72 "th",
73 "thead",
74 "tr",
75 "ul",
76]);
77
78const assistantProcessor = unified()
79 .use(remarkParse)
80 .use(CHAT_MARKDOWN_REMARK_PLUGINS)
81 .use(remarkRehype, { allowDangerousHtml: true })
82 .use(CHAT_MARKDOWN_REHYPE_PLUGINS);
83const assistantBreaksProcessor = unified()
84 .use(remarkParse)
85 .use(CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS)
86 .use(remarkRehype, { allowDangerousHtml: true })
87 .use(CHAT_MARKDOWN_REHYPE_PLUGINS);
88const userProcessor = unified()
89 .use(remarkParse)
90 .use(CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS)
91 .use(remarkRehype, { allowDangerousHtml: true });
92
93interface TextTree {
94 readonly type: string;
95 readonly tagName?: string;
96 readonly value?: string;
97 readonly properties?: {
98 readonly href?: unknown;
99 readonly src?: unknown;
100 readonly dataInlineCode?: unknown;
101 };
102 readonly children?: ReadonlyArray<TextTree>;
103}
104
105/** Uses the renderer's Markdown transforms, without mounting folded/virtualized rows. */
106function markdownThreadFindText(
107 markdown: string,
108 userMessage = false,
109 cwd?: string,
110 skills: readonly InlineSkill[] = [],
111 lineBreaks = false,
112): string[] {
113 const processor = userMessage
114 ? userProcessor
115 : lineBreaks
116 ? assistantBreaksProcessor
117 : assistantProcessor;
118 const tree = processor.runSync(processor.parse(markdown));
119 const paths = [
120 ...extractMarkdownLinkHrefs(renderCodexInlineDirectivesAsMarkdown(markdown)),
121 ...extractInlineCodeSpans(markdown).flatMap((span) => inlineCodeFilePathCandidate(span) ?? []),
122 ].flatMap((href) => {
123 const target = resolveMarkdownFileLinkTarget(href, cwd);
124 return target ? [splitFilePathPosition(target).path] : [];
125 });
126 const parentSuffixes = buildFileLinkParentSuffixByPath(paths);
127 const segments: string[] = [];
128 let text = "";
129 const flush = () => {
130 if (text.trim()) segments.push(text);
131 text = "";
132 };
133 const visit = (node: TextTree, inPre = false, inlineSkills = false) => {
134 const href = node.properties?.href ?? node.properties?.src;
135 if (userMessage && typeof href === "string" && parseComposerContextHref(href)) {
136 flush();
137 return;
138 }
139 const citation =
140 node.tagName === "a" && typeof href === "string" ? parseAssistantCitationHref(href) : null;
141 if (citation) {
142 // Matches the citation chip, which shows its label instead of the link text.
143 text += assistantCitationLabel(citation);
144 return;
145 }
146 {
147 let candidate: string | null = null;
148 if (node.tagName === "a" && typeof href === "string") {
149 candidate = href;
150 } else if (
151 node.tagName === "code" &&
152 !inPre &&
153 node.properties?.dataInlineCode !== undefined
154 ) {
155 candidate = inlineCodeFilePathCandidate(
156 (node.children ?? []).map((child) => child.value ?? "").join(""),
157 );
158 }
159 const target = candidate ? resolveMarkdownFileLinkTarget(candidate, cwd) : null;
160 if (target) {
161 const labelText = (node: TextTree): string =>
162 node.value ?? (node.children ?? []).map(labelText).join("");
163 const label = labelText(node);
164 if (node.tagName === "a" && candidate && !isMarkdownFileLinkLabel(label, candidate))
165 text += `${label} `;
166 text += fileLinkLabel(splitFilePathPosition(target), parentSuffixes);
167 return;
168 }
169 }
170 const template = artifactTemplateFromHastProperties(node.properties);
171 if (template) {
172 // Matches the card's visible text; its action button is not indexed.
173 flush();
174 segments.push(
175 template.displayName,
176 codexArtifactTemplatePresentationLabel(template.artifactKind),
177 );
178 return;
179 }
180 const block = THREAD_FIND_BLOCK_TAGS.has(node.tagName ?? "");
181 if (block) flush();
182 if (node.type === "text" || (userMessage && node.type === "raw")) {
183 let value = node.value ?? "";
184 if (inlineSkills) {
185 let cursor = 0;
186 let rendered = "";
187 for (const { start, end, skill } of matchInlineSkills(value, skills)) {
188 rendered += value.slice(cursor, start) + formatProviderSkillDisplayName(skill);
189 cursor = end;
190 }
191 value = rendered + value.slice(cursor);
192 }
193 text += inPre ? value : value.replace(/\r?\n/g, " ");
194 }
195 const renderSkills =
196 node.tagName === "code" || node.tagName === "a"
197 ? false
198 : inlineSkills || node.tagName === "p" || node.tagName === "li";
199 for (const child of node.children ?? [])
200 visit(child, inPre || node.tagName === "pre", renderSkills);
201 if (block) flush();
202 };
203 visit(tree);
204 flush();
205 return segments;
206}
207
208/** Plans render `$skill` tokens literally (no skill chips), so they are indexed as written. */
209export function searchablePlanSegments(markdown: string, cwd?: string): readonly string[] {
210 return [
211 proposedPlanTitle(markdown) ?? "Proposed plan",
212 ...markdownThreadFindText(stripDisplayedPlanMarkdown(markdown), false, cwd, []),
213 ];
214}
215
216export function searchableMessageSegments(
217 message: Pick<OrchestrationV2ConversationMessage, "role" | "text" | "streaming" | "context">,
218 cwd?: string,
219 skills: readonly InlineSkill[] = [],
220): readonly string[] | null {
221 if (message.role === "user") {
222 const text = message.context ? message.text : upgradeLegacyContextMessage(message.text).text;
223 return markdownThreadFindText(text, true, cwd, skills);
224 }
225 if (message.role !== "assistant") return null;
226 return markdownThreadFindText(
227 message.text || (message.streaming ? "" : "(empty response)"),
228 false,
229 cwd,
230 skills,
231 shouldPreserveAssistantLineBreaks(message.text),
232 );
233}