;
+ children?: unknown;
+}
+
+function normalizeQuery(query: string): string {
+ return query.trim().toLocaleLowerCase();
+}
+
+export function textContainsThreadSearchMatch(text: string, query: string): boolean {
+ return findMatchRanges(text, query).length > 0;
+}
+
+function findMatchRanges(text: string, query: string): TextMatchRange[] {
+ const normalizedQuery = normalizeQuery(query);
+ if (normalizedQuery.length === 0) {
+ return [];
+ }
+
+ const ranges: TextMatchRange[] = [];
+ let searchStart = 0;
+
+ while (searchStart < text.length) {
+ let matchEnd = searchStart;
+ let normalizedCandidate = "";
+ while (matchEnd < text.length && normalizedCandidate.length < normalizedQuery.length) {
+ matchEnd += 1;
+ normalizedCandidate = text.slice(searchStart, matchEnd).toLocaleLowerCase();
+ }
+ if (normalizedCandidate === normalizedQuery) {
+ ranges.push({
+ start: searchStart,
+ end: matchEnd,
+ });
+ searchStart = matchEnd;
+ continue;
+ }
+ searchStart += 1;
+ }
+
+ return ranges;
+}
+
+export function renderHighlightedText(
+ text: string,
+ query: string,
+ keyPrefix: string,
+ options?: { active?: boolean },
+): ReactNode {
+ const ranges = findMatchRanges(text, query);
+ if (ranges.length === 0) {
+ return text;
+ }
+
+ const nodes: ReactNode[] = [];
+ let cursor = 0;
+ for (const [index, range] of ranges.entries()) {
+ if (range.start > cursor) {
+ nodes.push(text.slice(cursor, range.start));
+ }
+ nodes.push(
+
+ {text.slice(range.start, range.end)}
+ ,
+ );
+ cursor = range.end;
+ }
+
+ if (cursor < text.length) {
+ nodes.push(text.slice(cursor));
+ }
+
+ return nodes;
+}
+
+function buildHastHighlightNode(value: string, active: boolean): HNode {
+ return {
+ type: "element",
+ tagName: "mark",
+ properties: {
+ "data-thread-search-highlight": active ? "active" : "match",
+ className: active ? ACTIVE_HIGHLIGHT_CLASS_NAME : MATCH_HIGHLIGHT_CLASS_NAME,
+ },
+ children: [
+ {
+ type: "text",
+ value,
+ },
+ ],
+ };
+}
+
+function splitTextNode(node: HNode, query: string, active: boolean): HNode[] {
+ const value = typeof node.value === "string" ? node.value : "";
+ const ranges = findMatchRanges(value, query);
+ if (ranges.length === 0) {
+ return [node];
+ }
+
+ const parts: HNode[] = [];
+ let cursor = 0;
+ for (const range of ranges) {
+ if (range.start > cursor) {
+ parts.push({
+ type: "text",
+ value: value.slice(cursor, range.start),
+ });
+ }
+ parts.push(buildHastHighlightNode(value.slice(range.start, range.end), active));
+ cursor = range.end;
+ }
+
+ if (cursor < value.length) {
+ parts.push({
+ type: "text",
+ value: value.slice(cursor),
+ });
+ }
+
+ return parts;
+}
+
+function isHNode(value: unknown): value is HNode {
+ return typeof value === "object" && value !== null && typeof (value as HNode).type === "string";
+}
+
+function visitTree(node: HNode, query: string, active: boolean): void {
+ const rawChildren = Array.isArray(node.children) ? node.children.filter(isHNode) : null;
+ if (!rawChildren || rawChildren.length === 0) {
+ return;
+ }
+
+ const nextChildren: HNode[] = [];
+ for (const child of rawChildren) {
+ if (child.type === "text") {
+ nextChildren.push(...splitTextNode(child, query, active));
+ continue;
+ }
+
+ visitTree(child, query, active);
+ nextChildren.push(child);
+ }
+
+ node.children = nextChildren;
+}
+
+export function createThreadSearchHighlightRehypePlugin(
+ query: string,
+ options?: { active?: boolean },
+): (() => (tree: unknown) => void) | undefined {
+ const normalizedQuery = normalizeQuery(query);
+ if (normalizedQuery.length === 0) {
+ return undefined;
+ }
+
+ return () => {
+ return (tree: unknown) => {
+ if (!isHNode(tree)) {
+ return;
+ }
+ visitTree(tree, normalizedQuery, options?.active ?? false);
+ };
+ };
+}
diff --git a/apps/web/src/components/chat/userMessageTerminalContexts.test.ts b/apps/web/src/components/chat/userMessageTerminalContexts.test.ts
index 110119501d2..d64b98d7fef 100644
--- a/apps/web/src/components/chat/userMessageTerminalContexts.test.ts
+++ b/apps/web/src/components/chat/userMessageTerminalContexts.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
buildInlineTerminalContextText,
+ buildRenderedUserMessageText,
formatInlineTerminalContextLabel,
textContainsInlineTerminalContextLabels,
} from "./userMessageTerminalContexts";
@@ -33,4 +34,27 @@ describe("userMessageTerminalContexts", () => {
]),
).toBe(false);
});
+
+ it("replaces hidden inline terminal tokens with the rendered chip labels", () => {
+ expect(
+ buildRenderedUserMessageText("yo @terminal-1:12-13 whats up", [
+ { header: "Terminal 1 lines 12-13" },
+ ]),
+ ).toBe("yo Terminal 1 lines 12-13 whats up");
+ });
+
+ it("ignores empty terminal context headers while replacing visible inline labels", () => {
+ expect(
+ buildRenderedUserMessageText("yo @terminal-1:12-13 whats up", [
+ { header: " " },
+ { header: "Terminal 1 lines 12-13" },
+ ]),
+ ).toBe("yo Terminal 1 lines 12-13 whats up");
+ });
+
+ it("prefixes standalone rendered chip labels ahead of the remaining text", () => {
+ expect(
+ buildRenderedUserMessageText("follow-up text", [{ header: "Terminal 1 lines 12-13" }]),
+ ).toBe("Terminal 1 lines 12-13 follow-up text");
+ });
});
diff --git a/apps/web/src/components/chat/userMessageTerminalContexts.ts b/apps/web/src/components/chat/userMessageTerminalContexts.ts
index 978210a53f4..0d626a7f3fc 100644
--- a/apps/web/src/components/chat/userMessageTerminalContexts.ts
+++ b/apps/web/src/components/chat/userMessageTerminalContexts.ts
@@ -14,6 +14,14 @@ export function buildInlineTerminalContextText(
.join(" ");
}
+function visibleTerminalContextHeaders(
+ contexts: ReadonlyArray<{
+ header: string;
+ }>,
+): string[] {
+ return contexts.map((context) => context.header.trim()).filter((header) => header.length > 0);
+}
+
export function formatInlineTerminalContextLabel(header: string): string {
const trimmedHeader = header.trim();
const match = TERMINAL_CONTEXT_HEADER_PATTERN.exec(trimmedHeader);
@@ -53,3 +61,39 @@ export function textContainsInlineTerminalContextLabels(
return true;
}
+
+export function buildRenderedUserMessageText(
+ text: string,
+ contexts: ReadonlyArray<{
+ header: string;
+ }>,
+): string {
+ const headers = visibleTerminalContextHeaders(contexts);
+ if (headers.length === 0) {
+ return text;
+ }
+
+ const visibleContexts = contexts.filter((context) => context.header.trim().length > 0);
+
+ if (textContainsInlineTerminalContextLabels(text, visibleContexts)) {
+ let cursor = 0;
+ let renderedText = "";
+
+ for (const context of visibleContexts) {
+ const replacement = context.header.trim();
+ const label = formatInlineTerminalContextLabel(context.header);
+ const matchIndex = text.indexOf(label, cursor);
+ if (matchIndex === -1) {
+ return text;
+ }
+
+ renderedText += text.slice(cursor, matchIndex);
+ renderedText += replacement;
+ cursor = matchIndex + label.length;
+ }
+
+ return renderedText + text.slice(cursor);
+ }
+
+ return text.length > 0 ? `${headers.join(" ")} ${text}` : headers.join(" ");
+}
diff --git a/apps/web/src/lib/markdownPlainText.test.ts b/apps/web/src/lib/markdownPlainText.test.ts
new file mode 100644
index 00000000000..e1efe851f00
--- /dev/null
+++ b/apps/web/src/lib/markdownPlainText.test.ts
@@ -0,0 +1,41 @@
+import { describe, expect, it } from "vitest";
+
+import { markdownToPlainText } from "./markdownPlainText";
+
+describe("markdownToPlainText", () => {
+ it("keeps rendered link text and removes raw markdown destinations", () => {
+ expect(
+ markdownToPlainText("See [thread search docs](https://example.com/thread-search) next."),
+ ).toBe("See thread search docs next.");
+ });
+
+ it("keeps visible code text while removing markdown fence syntax", () => {
+ expect(markdownToPlainText("```ts\nconst marker = 'alpha';\n```")).toBe(
+ "const marker = 'alpha';",
+ );
+ });
+
+ it("strips single-delimiter emphasis and decodes rendered entities", () => {
+ expect(markdownToPlainText("it is *important* to decode <div> and _notes_.")).toBe(
+ "it is important to decode and notes.",
+ );
+ });
+
+ it("leaves invalid surrogate numeric entities unchanged instead of throwing", () => {
+ expect(markdownToPlainText("bad entity")).toBe("bad entity");
+ });
+
+ it("strips empty fenced code blocks without leaving fence syntax behind", () => {
+ expect(markdownToPlainText("Before\n```\n```\nAfter")).toBe("Before\n\n\nAfter");
+ });
+
+ it("removes the first-line markdown structure while preserving visible content", () => {
+ expect(
+ markdownToPlainText("# Heading\n\n## Summary\n\n- **alpha marker**\n- `thread search`"),
+ ).toBe("Heading\nSummary\nalpha marker\nthread search");
+ });
+
+ it("strips nested blockquote markers while preserving quoted text", () => {
+ expect(markdownToPlainText("> > nested quote")).toBe("nested quote");
+ });
+});
diff --git a/apps/web/src/lib/markdownPlainText.ts b/apps/web/src/lib/markdownPlainText.ts
new file mode 100644
index 00000000000..0eeb327e826
--- /dev/null
+++ b/apps/web/src/lib/markdownPlainText.ts
@@ -0,0 +1,80 @@
+import { decodeNamedCharacterReference } from "decode-named-character-reference";
+
+const FENCED_CODE_BLOCK_REGEX = /(^|\n)(`{3,}|~{3,})[^\n]*(?:\n([\s\S]*?))?\n?\2(?=\n|$)/g;
+const INLINE_CODE_REGEX = /`([^`\n]+)`/g;
+const IMAGE_LINK_REGEX = /!\[([^\]]*)\]\((?:\\.|[^)])*\)/g;
+const INLINE_LINK_REGEX = /\[([^\]]+)\]\((?:\\.|[^)])*\)/g;
+const REFERENCE_LINK_REGEX = /\[([^\]]+)\]\[[^\]]*]/g;
+const REFERENCE_DEFINITION_REGEX = /^\s{0,3}\[[^\]]+]:\s+\S+(?:\s+.+)?$/gm;
+const HTML_COMMENT_REGEX = //g;
+const AUTOLINK_REGEX = /<((?:https?|mailto):[^>\s]+)>/g;
+const HTML_TAG_REGEX = /<\/?[^>\n]+>/g;
+const HEADING_PREFIX_REGEX = /^\s{0,3}#{1,6}\s+/gm;
+const BLOCKQUOTE_PREFIX_REGEX = /^(\s{0,3}>[ \t]*)+/gm;
+const LIST_PREFIX_REGEX = /^\s{0,3}(?:[-+*]|\d+[.)])\s+/gm;
+const THEMATIC_BREAK_REGEX = /^\s{0,3}(?:[-*_]\s*){3,}$/gm;
+const EMPHASIS_REGEXES = [/(\*\*\*|___)(.*?)\1/gs, /(\*\*|__)(.*?)\1/gs, /(~~)(.*?)\1/gs] as const;
+const SINGLE_ASTERISK_EMPHASIS_REGEX = /(^|[^\w*])\*(?!\s)([^*\n]+?)(? MAX_UNICODE_CODE_POINT ||
+ (codePoint >= MIN_SURROGATE_CODE_POINT && codePoint <= MAX_SURROGATE_CODE_POINT)
+ ) {
+ return entity;
+ }
+
+ return String.fromCodePoint(codePoint);
+}
+
+function decodeHtmlEntities(text: string): string {
+ return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z][a-z0-9]+);/gi, (entity, rawValue: string) => {
+ if (rawValue.startsWith("#")) {
+ return decodeNumericHtmlEntity(entity, rawValue);
+ }
+
+ return decodeNamedCharacterReference(rawValue) || entity;
+ });
+}
+
+export function markdownToPlainText(markdown: string): string {
+ let text = markdown.replace(/\r\n?/g, "\n");
+
+ text = text.replace(HTML_COMMENT_REGEX, " ");
+ text = text.replace(
+ FENCED_CODE_BLOCK_REGEX,
+ (_match, prefix: string, _fence: string, code?: string) => `${prefix}${code ?? ""}\n`,
+ );
+ text = text.replace(IMAGE_LINK_REGEX, "$1");
+ text = text.replace(INLINE_LINK_REGEX, "$1");
+ text = text.replace(REFERENCE_LINK_REGEX, "$1");
+ text = text.replace(REFERENCE_DEFINITION_REGEX, "");
+ text = text.replace(AUTOLINK_REGEX, "$1");
+ text = text.replace(INLINE_CODE_REGEX, "$1");
+
+ for (const regex of EMPHASIS_REGEXES) {
+ text = text.replace(regex, "$2");
+ }
+ text = text.replace(SINGLE_ASTERISK_EMPHASIS_REGEX, "$1$2");
+ text = text.replace(SINGLE_UNDERSCORE_EMPHASIS_REGEX, "$1$2");
+
+ text = text.replace(HEADING_PREFIX_REGEX, "");
+ text = text.replace(BLOCKQUOTE_PREFIX_REGEX, "");
+ text = text.replace(LIST_PREFIX_REGEX, "");
+ text = text.replace(THEMATIC_BREAK_REGEX, "");
+ text = text.replace(HTML_TAG_REGEX, " ");
+ text = decodeHtmlEntities(text);
+
+ return text.trim();
+}
diff --git a/apps/web/src/markdown-links.ts b/apps/web/src/markdown-links.ts
index 9ec4ee4564c..e103087bb71 100644
--- a/apps/web/src/markdown-links.ts
+++ b/apps/web/src/markdown-links.ts
@@ -9,6 +9,7 @@ const RELATIVE_FILE_PATH_PATTERN = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+(?::\d
const RELATIVE_FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+\.[A-Za-z0-9_-]+(?::\d+){0,2}$/;
const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/;
const POSITION_ONLY_PATTERN = /^\d+(?::\d+)?$/;
+const MARKDOWN_LINK_HREF_PATTERN = /\[[^\]]*]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g;
const POSIX_FILE_ROOT_PREFIXES = [
"/Users/",
"/home/",
@@ -174,6 +175,97 @@ function basenameOfPath(path: string): string {
return separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path;
}
+function pathParentSegments(path: string): string[] {
+ const normalized = path.replaceAll("\\", "/");
+ const segments = normalized.split("/").filter((segment) => segment.length > 0);
+ return segments.slice(0, -1);
+}
+
+export function buildFileLinkParentSuffixByPath(
+ filePaths: ReadonlyArray,
+): Map {
+ const groups = new Map>();
+ for (const filePath of filePaths) {
+ const pathSegments = filePath
+ .replaceAll("\\", "/")
+ .split("/")
+ .filter((segment) => segment.length > 0);
+ const basename = pathSegments[pathSegments.length - 1];
+ if (!basename) continue;
+ const group = groups.get(basename) ?? new Set();
+ group.add(filePath);
+ groups.set(basename, group);
+ }
+
+ const suffixByPath = new Map();
+ for (const group of groups.values()) {
+ const uniquePaths = [...group];
+ if (uniquePaths.length < 2) continue;
+
+ const parentSegmentsByPath = new Map(
+ uniquePaths.map((filePath) => [filePath, pathParentSegments(filePath)]),
+ );
+ const minUniqueDepthByPath = new Map();
+
+ for (const filePath of uniquePaths) {
+ const segments = parentSegmentsByPath.get(filePath) ?? [];
+ let resolvedDepth = segments.length;
+ for (let depth = 1; depth <= segments.length; depth += 1) {
+ const candidate = segments.slice(-depth).join("/");
+ const collision = uniquePaths.some((otherPath) => {
+ if (otherPath === filePath) return false;
+ const otherSegments = parentSegmentsByPath.get(otherPath) ?? [];
+ return otherSegments.slice(-depth).join("/") === candidate;
+ });
+ if (!collision) {
+ resolvedDepth = depth;
+ break;
+ }
+ }
+ minUniqueDepthByPath.set(filePath, resolvedDepth);
+ }
+
+ for (const filePath of uniquePaths) {
+ const segments = parentSegmentsByPath.get(filePath) ?? [];
+ if (segments.length === 0) continue;
+ const minUniqueDepth = minUniqueDepthByPath.get(filePath) ?? 1;
+ const suffixDepth = Math.min(segments.length, Math.max(minUniqueDepth, 2));
+ suffixByPath.set(filePath, segments.slice(-suffixDepth).join("/"));
+ }
+ }
+
+ return suffixByPath;
+}
+
+export function buildMarkdownFileLinkLabel(
+ meta: Pick,
+ parentSuffix?: string | undefined,
+): string {
+ const labelParts = [meta.basename];
+ if (typeof parentSuffix === "string" && parentSuffix.length > 0) {
+ labelParts.push(parentSuffix);
+ }
+ if (meta.line) {
+ labelParts.push(`L${meta.line}${meta.column ? `:C${meta.column}` : ""}`);
+ }
+ return labelParts.join(" · ");
+}
+
+export function extractMarkdownLinkHrefs(text: string): string[] {
+ const hrefs: string[] = [];
+ for (const match of text.matchAll(MARKDOWN_LINK_HREF_PATTERN)) {
+ const href = match[1]?.trim();
+ if (!href) continue;
+ hrefs.push(href);
+ }
+ return hrefs;
+}
+
+export function normalizeMarkdownLinkHrefKey(href: string): string {
+ const normalizedHref = normalizeMarkdownLinkDestination(href);
+ return rewriteMarkdownFileUriHref(normalizedHref) ?? normalizedHref;
+}
+
export function resolveMarkdownFileLinkMeta(
href: string | undefined,
cwd?: string,
@@ -196,3 +288,22 @@ export function resolveMarkdownFileLinkMeta(
...(columnNumber !== undefined ? { column: columnNumber } : {}),
};
}
+
+export function collectMarkdownFileLinkLabels(text: string, cwd?: string): string[] {
+ const metaByHref = new Map();
+ for (const href of extractMarkdownLinkHrefs(text)) {
+ const normalizedHref = normalizeMarkdownLinkHrefKey(href);
+ if (metaByHref.has(normalizedHref)) continue;
+ const meta = resolveMarkdownFileLinkMeta(normalizedHref, cwd);
+ if (meta) {
+ metaByHref.set(normalizedHref, meta);
+ }
+ }
+
+ const fileLinkParentSuffixByPath = buildFileLinkParentSuffixByPath(
+ [...metaByHref.values()].map((meta) => meta.filePath),
+ );
+ return [...metaByHref.values()].map((meta) =>
+ buildMarkdownFileLinkLabel(meta, fileLinkParentSuffixByPath.get(meta.filePath)),
+ );
+}
diff --git a/bun.lock b/bun.lock
index ffc4a5922bd..275bf2bdf80 100644
--- a/bun.lock
+++ b/bun.lock
@@ -17,7 +17,7 @@
},
"apps/desktop": {
"name": "@t3tools/desktop",
- "version": "0.0.23",
+ "version": "0.0.24",
"dependencies": {
"@effect/platform-node": "catalog:",
"@t3tools/contracts": "workspace:*",
@@ -50,7 +50,7 @@
},
"apps/server": {
"name": "t3",
- "version": "0.0.23",
+ "version": "0.0.24",
"bin": {
"t3": "./dist/bin.mjs",
},
@@ -83,7 +83,7 @@
},
"apps/web": {
"name": "@t3tools/web",
- "version": "0.0.23",
+ "version": "0.0.24",
"dependencies": {
"@base-ui/react": "^1.4.1",
"@dnd-kit/core": "^6.3.1",
@@ -104,6 +104,7 @@
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"class-variance-authority": "^0.7.1",
+ "decode-named-character-reference": "^1.3.0",
"effect": "catalog:",
"lexical": "^0.41.0",
"lucide-react": "^0.564.0",
@@ -165,7 +166,7 @@
},
"packages/contracts": {
"name": "@t3tools/contracts",
- "version": "0.0.23",
+ "version": "0.0.24",
"dependencies": {
"effect": "catalog:",
},