import { useAtomValue } from "@effect/atom-react";import { CheckIcon, ChevronRightIcon, CopyIcon, GlobeIcon, InfoIcon, LightbulbIcon, Maximize2Icon, MessageSquareWarningIcon, Minimize2Icon, OctagonAlertIcon, TriangleAlertIcon, WrapTextIcon,} from "lucide-react";import type { EnvironmentId, ScopedThreadRef, ServerProviderSkill, ThreadLinkedPullRequest,} from "@t3tools/contracts";import { isAtomCommandInterrupted, squashAtomCommandFailure, type AtomCommandResult,} from "@t3tools/client-runtime/state/runtime";import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images";import * as Cause from "effect/Cause";import { AsyncResult } from "effect/unstable/reactivity";import React, { Children, Suspense, type ClipboardEvent as ReactClipboardEvent, type MouseEvent as ReactMouseEvent, isValidElement, use, useCallback, memo, useEffect, useMemo, useRef, useState, type ReactNode,} from "react";import type { Components, Options as ReactMarkdownOptions } from "react-markdown";import ReactMarkdown from "react-markdown";import { defaultUrlTransform } from "react-markdown";import rehypeRaw from "rehype-raw";import rehypeSanitize, { defaultSchema } from "rehype-sanitize";import remarkBreaks from "remark-breaks";import remarkGfm from "remark-gfm";import { remarkGithubAlerts } from "../markdown-github-alerts";import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText";import { CHAT_FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip";import { PierreEntryIcon } from "./chat/PierreEntryIcon";import { revealInFileExplorerLabelForKind, revealInFileExplorerLabelForOs,} from "./preview/fileExplorerLabel";import { resolveExternalWebLinkHost, showExternalLinkContextMenu,} from "./chat/externalLinkContextMenu";import { hasSpecificPierreIconForFileName, syntheticFileNameForLanguageId } from "../pierre-icons";import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";import { Button } from "./ui/button";import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "./ui/collapsible";import { ScrollArea } from "./ui/scroll-area";import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu";import { stackedThreadToast, toastManager } from "./ui/toast";import { recordVisitForThread } from "../browserHistoryStore";import { PreferredEditorEnvironmentRequiredError, useOpenInPreferredEditor, usePreferredEditor,} from "../editorPreferences";import { openInEditorMenuLabel } from "../editorLabels";import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering";import { fnv1a32 } from "../lib/diffRendering";import { LRUCache } from "../lib/lruCache";import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting";import { RenderErrorBoundary } from "./RenderErrorBoundary";import { useTheme } from "../hooks/useTheme";import { getClientSettings } from "../hooks/useSettings";import { chatMarkdownClipboardPayload, serializeTableElementToCsv, serializeTableElementToMarkdown,} from "../markdown-clipboard";import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation";import { extractMarkdownLinkHrefs, normalizeMarkdownLinkDestination, resolveInlineCodeFileLinkMeta, resolveMarkdownFileLinkMeta, rewriteMarkdownFileUriHref, shouldOpenMarkdownFileLinkInBrowserByDefault, shouldOpenMarkdownFileLinkInEditor, type MarkdownFileLinkMeta,} from "../markdown-links";import { readLocalApi } from "../localApi";import { useAssetUrlState } from "../assets/assetUrls";import { cn } from "../lib/utils";import { useRemoteOpenResolution, type RemoteOpenMode } from "../remoteOpen";import { useRightPanelStore } from "../rightPanelStore";import { readThreadShell, useProjects } from "../state/entities";import { serverEnvironment } from "../state/server";import { shellEnvironment } from "../state/shell";import { assetEnvironment } from "../state/assets";import { usePreparedConnection } from "../state/session";import { previewEnvironment } from "../state/preview";import { useAtomCommand } from "../state/use-atom-command";import { useAtomQueryRunner } from "../state/use-atom-query-runner";import { projectEnvironment } from "../state/projects";import { threadEnvironment } from "../state/threads";import { claimWorkspaceBasenameLookup, needsWorkspaceBasenameLookup, pickWorkspaceBasenameMatch, WORKSPACE_BASENAME_LOOKUP_LIMIT,} from "../workspaceBasenameLookup";import { findProjectForChangeRequest, matchesLinkedPullRequestUrl, parseChangeRequestUrl, useOpenChangeRequestLink,} from "~/lib/openPullRequestLink";import { writeTextToClipboard } from "../hooks/useCopyToClipboard";import { isPreviewSupportedInRuntime } from "../previewStateStore";import { resolvePathLinkTarget } from "../terminal-links";import { isBrowserPreviewFile, openFileInPreview, openUrlInPreview, BrowserPreviewUnavailableError,} from "../browser/openFileInPreview"; interface ChatMarkdownProps { text: string; cwd: string | undefined; threadRef?: ScopedThreadRef | undefined; /** Environment that owns non-thread markdown, such as a pull request panel. */ environmentId?: EnvironmentId | undefined; onTaskListChange?: ((input: { markerOffset: number; checked: boolean }) => void) | undefined; isStreaming?: boolean; skills?: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>; className?: string; /** Treat single newlines as hard breaks — chat-style user input. */ lineBreaks?: boolean; /** Parse sanitized raw HTML instead of displaying its source text. */ parseRawHtml?: boolean;} export function canUseMarkdownFileShellActions( environmentId: EnvironmentId | null, remoteOpenMode: RemoteOpenMode, isRemoteOpenResolved: boolean,): boolean { return environmentId !== null && isRemoteOpenResolved && remoteOpenMode === "local-exec";} export function hasMarkdownFilePrimaryAction(input: { canOpenInEditor: boolean; canOpenInBrowser: boolean; canOpenInPanel: boolean;}): boolean { return input.canOpenInEditor || input.canOpenInBrowser || input.canOpenInPanel;} export function shouldUseMarkdownFileBrowserPrimaryAction(input: { iconPath: string; canOpenInEditor: boolean; canOpenInBrowser: boolean; canOpenInPanel: boolean;}): boolean { return ( input.canOpenInBrowser && (shouldOpenMarkdownFileLinkInBrowserByDefault(input.iconPath) || (!input.canOpenInEditor && !input.canOpenInPanel)) );} const EMPTY_MARKDOWN_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = []; const CODE_FENCE_LANGUAGE_REGEX = /(?:^|\s)language-([^\s]+)/;const WINDOWS_DRIVE_PATH_REGEX = /^[A-Za-z]:[\\/]/;const MAX_HIGHLIGHT_CACHE_ENTRIES = 500;const MAX_HIGHLIGHT_CACHE_MEMORY_BYTES = 50 * 1024 * 1024; interface MarkdownActionFailureContext { readonly operation: string; readonly target?: string; readonly format?: "markdown" | "csv"; readonly language?: string; readonly fenceTitle?: string; readonly copyTarget?: string;} function reportMarkdownActionFailure(context: MarkdownActionFailureContext, cause: unknown): void { console.error("[chat-markdown] action failed", context, cause);} const highlightedCodeCache = new LRUCache<string>( MAX_HIGHLIGHT_CACHE_ENTRIES, MAX_HIGHLIGHT_CACHE_MEMORY_BYTES,); function findTaskListMarkerOffset(markdown: string, listItemStart: number): number | null { const firstLineEnd = markdown.indexOf("\n", listItemStart); const firstLine = markdown.slice( listItemStart, firstLineEnd === -1 ? markdown.length : firstLineEnd, ); const match = firstLine.match(/^(?:\s*(?:[-+*]|\d+[.)])\s+)(\[[ xX]\])/); if (!match?.[1]) return null; return listItemStart + firstLine.indexOf(match[1]);} /** * The default `1.25rem` marker gutter (`.chat-markdown ol`) fits markers up to * two characters wide. Once a marker reaches three characters (item 100+), * `list-style-position: outside` paints it wider than that gutter and clips * the leading character against the item's own overflow. Rather than widening * the gutter for every list, only lists whose widest marker is 3+ characters * get a wider `--list-gutter`. The width includes a negative marker's minus * sign. */export function orderedListGutterStyle( itemCount: number, start: unknown,): { "--list-gutter": string } | undefined { const parsedStart = Number.parseInt(String(start ?? 1), 10); const firstNumber = Number.isNaN(parsedStart) ? 1 : parsedStart; const lastNumber = firstNumber + Math.max(itemCount - 1, 0); const markerWidth = Math.max(String(firstNumber).length, String(lastNumber).length); if (markerWidth <= 2) return undefined; return { "--list-gutter": `${markerWidth + 1}ch` };} type MarkdownHtmlAstNode = { type?: string; tagName?: string; properties?: Record<string, unknown>; children?: MarkdownHtmlAstNode[];}; /** Preserve Windows drive paths through the protocol allowlist in rehype-sanitize. */function rehypeNormalizeWindowsImageSrc() { return (tree: MarkdownHtmlAstNode) => { const visit = (node: MarkdownHtmlAstNode) => { const src = node.properties?.src; if ( node.type === "element" && node.tagName === "img" && typeof src === "string" && WINDOWS_DRIVE_PATH_REGEX.test(src) ) { node.properties = { ...node.properties, src: `file:///${src.replaceAll("\\", "/")}`, }; } node.children?.forEach(visit); }; visit(tree); };} const CHAT_MARKDOWN_SANITIZE_SCHEMA = { ...defaultSchema, attributes: { ...defaultSchema.attributes, "*": (defaultSchema.attributes?.["*"] ?? []).filter((attribute) => attribute !== "title"), code: [...(defaultSchema.attributes?.code ?? []), "dataCodeMeta", "dataInlineCode"], blockquote: [...(defaultSchema.attributes?.blockquote ?? []), "dataAlert"], }, protocols: { ...defaultSchema.protocols, href: [...(defaultSchema.protocols?.href ?? []), "file"], src: [...(defaultSchema.protocols?.src ?? []), "file"], },} satisfies Parameters<typeof rehypeSanitize>[0]; const CHAT_MARKDOWN_REMARK_PLUGINS = [ remarkGfm, remarkGithubAlerts, remarkNormalizeListItemIndentation, remarkPreserveCodeMeta, remarkNormalizeLinksAndTagInlineCode,] satisfies NonNullable<ReactMarkdownOptions["remarkPlugins"]>; const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ remarkGfm, remarkGithubAlerts, remarkNormalizeListItemIndentation, remarkBreaks, remarkPreserveCodeMeta, remarkNormalizeLinksAndTagInlineCode,] satisfies NonNullable<ReactMarkdownOptions["remarkPlugins"]>; const CHAT_MARKDOWN_REHYPE_PLUGINS = [ rehypeRaw, rehypeNormalizeWindowsImageSrc, [rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA],] satisfies NonNullable<ReactMarkdownOptions["rehypePlugins"]>; /** GitHub's own five alert kinds, in its colors: the glyph names the urgency, the title says it. */const GITHUB_ALERT_PRESENTATIONS: Record< string, { label: string; Icon: typeof InfoIcon; borderClassName: string; titleClassName: string }> = { note: { label: "Note", Icon: InfoIcon, borderClassName: "border-blue-500/70", titleClassName: "text-blue-600 dark:text-blue-400", }, tip: { label: "Tip", Icon: LightbulbIcon, borderClassName: "border-emerald-500/70", titleClassName: "text-emerald-600 dark:text-emerald-400", }, important: { label: "Important", Icon: MessageSquareWarningIcon, borderClassName: "border-purple-500/70", titleClassName: "text-purple-600 dark:text-purple-400", }, warning: { label: "Warning", Icon: TriangleAlertIcon, borderClassName: "border-amber-500/70", titleClassName: "text-amber-600 dark:text-amber-500", }, caution: { label: "Caution", Icon: OctagonAlertIcon, borderClassName: "border-red-500/70", titleClassName: "text-red-600 dark:text-red-400", },}; function extractFenceLanguage(className: string | undefined): string { const match = className?.match(CODE_FENCE_LANGUAGE_REGEX); const raw = match?.[1] ?? "text"; // Shiki doesn't bundle a gitignore grammar; ini is a close match (#685) return raw === "gitignore" ? "ini" : raw;} const FENCE_TITLE_ATTR_REGEX = /(?:^|\s)(?:title|file(?:name)?)=(?:"([^"]+)"|'([^']+)'|(\S+))/i;const FENCE_FILENAME_TOKEN_REGEX = /^[\w@][\w@./-]*\.[A-Za-z0-9]+$/; /** Pulls a filename out of fence meta: ```ts title="x.ts" / ```ts src/main.ts */function extractFenceTitle(meta: string | undefined): string | null { if (!meta) return null; const attrMatch = FENCE_TITLE_ATTR_REGEX.exec(meta); const attrTitle = attrMatch?.[1] ?? attrMatch?.[2] ?? attrMatch?.[3]; if (attrTitle) return attrTitle; return meta.split(/\s+/).find((candidate) => FENCE_FILENAME_TOKEN_REGEX.test(candidate)) ?? null;} function extractPreCodeMeta(node: unknown): string | undefined { const children = ( node as | { children?: Array<{ type?: string; tagName?: string; data?: { meta?: unknown }; properties?: { dataCodeMeta?: unknown }; }>; } | undefined )?.children; const codeNode = children?.find((child) => child?.type === "element" && child.tagName === "code"); const meta = codeNode?.properties?.dataCodeMeta ?? codeNode?.data?.meta; return typeof meta === "string" && meta.trim().length > 0 ? meta.trim() : undefined;} type MarkdownAstNode = { type?: string; meta?: unknown; url?: string; data?: { hProperties?: Record<string, unknown>; }; children?: MarkdownAstNode[];}; function remarkPreserveCodeMeta() { return (tree: MarkdownAstNode) => { const visit = (node: MarkdownAstNode) => { if (node.type === "code" && typeof node.meta === "string" && node.meta.trim().length > 0) { node.data = { ...node.data, hProperties: { ...node.data?.hProperties, dataCodeMeta: node.meta.trim(), }, }; } node.children?.forEach(visit); }; visit(tree); };} /** * Preserve Windows drive links as allowed `file:` URLs before sanitization. * The same traversal tags inline code while it can still be distinguished * from fenced code. Code inside links stays untagged to avoid nested anchors. */function remarkNormalizeLinksAndTagInlineCode() { return (tree: MarkdownAstNode) => { const visit = (node: MarkdownAstNode, insideLink: boolean) => { if ( (node.type === "link" || node.type === "definition") && typeof node.url === "string" && WINDOWS_DRIVE_PATH_REGEX.test(node.url) ) { node.url = `file:///${node.url.replaceAll("\\", "/")}`; } if (node.type === "inlineCode" && !insideLink) { node.data = { ...node.data, hProperties: { ...node.data?.hProperties, dataInlineCode: "", }, }; } const childInsideLink = insideLink || node.type === "link" || node.type === "linkReference"; node.children?.forEach((child) => visit(child, childInsideLink)); }; visit(tree, false); };} function nodeToPlainText(node: ReactNode): string { if (typeof node === "string" || typeof node === "number") { return String(node); } if (Array.isArray(node)) { return node.map((child) => nodeToPlainText(child)).join(""); } if (isValidElement<{ children?: ReactNode }>(node)) { return nodeToPlainText(node.props.children); } return "";} function extractCodeBlock( children: ReactNode,): { className: string | undefined; code: string } | null { const childNodes = Children.toArray(children); if (childNodes.length !== 1) { return null; } const onlyChild = childNodes[0]; if ( !isValidElement<{ className?: string; children?: ReactNode; node?: { tagName?: string } }>( onlyChild, ) ) { return null; } // With a custom `code` component the child's type is that component, not // the "code" tag — the hast node react-markdown attaches still names it. if (onlyChild.type !== "code" && onlyChild.props.node?.tagName !== "code") { return null; } return { className: onlyChild.props.className, code: nodeToPlainText(onlyChild.props.children), };} function createHighlightCacheKey(code: string, language: string, themeName: DiffThemeName): string { return `${fnv1a32(code).toString(36)}:${code.length}:${language}:${themeName}`;} function estimateHighlightedSize(html: string, code: string): number { return Math.max(html.length * 2, code.length * 3);} function readInitialWordWrapSetting(): boolean { return getClientSettings().wordWrap;} function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) { const containerRef = useRef<HTMLDivElement | null>(null); const tableRef = useRef<HTMLTableElement | null>(null); const [expanded, setExpanded] = useState(readInitialWordWrapSetting); const [copied, setCopied] = useState(false);Lines 1–500 of 2262. Load more
import { useAtomValue } from "@effect/atom-react"; import { CheckIcon, ChevronRightIcon, CopyIcon, GlobeIcon, InfoIcon, LightbulbIcon, Maximize2Icon, MessageSquareWarningIcon, Minimize2Icon, OctagonAlertIcon, TriangleAlertIcon, WrapTextIcon, } from "lucide-react"; import type { EnvironmentId, ScopedThreadRef, ServerProviderSkill, ThreadLinkedPullRequest, } from "@t3tools/contracts"; import { isAtomCommandInterrupted, squashAtomCommandFailure, type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; import React, { Children, Suspense, type ClipboardEvent as ReactClipboardEvent, type MouseEvent as ReactMouseEvent, isValidElement, use, useCallback, memo, useEffect, useMemo, useRef, useState, type ReactNode, } from "react"; import type { Components, Options as ReactMarkdownOptions } from "react-markdown"; import ReactMarkdown from "react-markdown"; import { defaultUrlTransform } from "react-markdown"; import rehypeRaw from "rehype-raw"; import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; import remarkBreaks from "remark-breaks"; import remarkGfm from "remark-gfm"; import { remarkGithubAlerts } from "../markdown-github-alerts"; import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText"; import { CHAT_FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip"; import { PierreEntryIcon } from "./chat/PierreEntryIcon"; import { revealInFileExplorerLabelForKind, revealInFileExplorerLabelForOs, } from "./preview/fileExplorerLabel"; import { resolveExternalWebLinkHost, showExternalLinkContextMenu, } from "./chat/externalLinkContextMenu"; import { hasSpecificPierreIconForFileName, syntheticFileNameForLanguageId } from "../pierre-icons"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { Button } from "./ui/button"; import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "./ui/collapsible"; import { ScrollArea } from "./ui/scroll-area"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { recordVisitForThread } from "../browserHistoryStore"; import { PreferredEditorEnvironmentRequiredError, useOpenInPreferredEditor, usePreferredEditor, } from "../editorPreferences"; import { openInEditorMenuLabel } from "../editorLabels"; import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; import { fnv1a32 } from "../lib/diffRendering"; import { LRUCache } from "../lib/lruCache"; import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting"; import { RenderErrorBoundary } from "./RenderErrorBoundary"; import { useTheme } from "../hooks/useTheme"; import { getClientSettings } from "../hooks/useSettings"; import { chatMarkdownClipboardPayload, serializeTableElementToCsv, serializeTableElementToMarkdown, } from "../markdown-clipboard"; import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation"; import { extractMarkdownLinkHrefs, normalizeMarkdownLinkDestination, resolveInlineCodeFileLinkMeta, resolveMarkdownFileLinkMeta, rewriteMarkdownFileUriHref, shouldOpenMarkdownFileLinkInBrowserByDefault, shouldOpenMarkdownFileLinkInEditor, type MarkdownFileLinkMeta, } from "../markdown-links"; import { readLocalApi } from "../localApi"; import { useAssetUrlState } from "../assets/assetUrls"; import { cn } from "../lib/utils"; import { useRemoteOpenResolution, type RemoteOpenMode } from "../remoteOpen"; import { useRightPanelStore } from "../rightPanelStore"; import { readThreadShell, useProjects } from "../state/entities"; import { serverEnvironment } from "../state/server"; import { shellEnvironment } from "../state/shell"; import { assetEnvironment } from "../state/assets"; import { usePreparedConnection } from "../state/session"; import { previewEnvironment } from "../state/preview"; import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { projectEnvironment } from "../state/projects"; import { threadEnvironment } from "../state/threads"; import { claimWorkspaceBasenameLookup, needsWorkspaceBasenameLookup, pickWorkspaceBasenameMatch, WORKSPACE_BASENAME_LOOKUP_LIMIT, } from "../workspaceBasenameLookup"; import { findProjectForChangeRequest, matchesLinkedPullRequestUrl, parseChangeRequestUrl, useOpenChangeRequestLink, } from "~/lib/openPullRequestLink"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { isPreviewSupportedInRuntime } from "../previewStateStore"; import { resolvePathLinkTarget } from "../terminal-links"; import { isBrowserPreviewFile, openFileInPreview, openUrlInPreview, BrowserPreviewUnavailableError, } from "../browser/openFileInPreview"; interface ChatMarkdownProps { text: string; cwd: string | undefined; threadRef?: ScopedThreadRef | undefined; /** Environment that owns non-thread markdown, such as a pull request panel. */ environmentId?: EnvironmentId | undefined; onTaskListChange?: ((input: { markerOffset: number; checked: boolean }) => void) | undefined; isStreaming?: boolean; skills?: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>; className?: string; /** Treat single newlines as hard breaks — chat-style user input. */ lineBreaks?: boolean; /** Parse sanitized raw HTML instead of displaying its source text. */ parseRawHtml?: boolean; } export function canUseMarkdownFileShellActions( environmentId: EnvironmentId | null, remoteOpenMode: RemoteOpenMode, isRemoteOpenResolved: boolean, ): boolean { return environmentId !== null && isRemoteOpenResolved && remoteOpenMode === "local-exec"; } export function hasMarkdownFilePrimaryAction(input: { canOpenInEditor: boolean; canOpenInBrowser: boolean; canOpenInPanel: boolean; }): boolean { return input.canOpenInEditor || input.canOpenInBrowser || input.canOpenInPanel; } export function shouldUseMarkdownFileBrowserPrimaryAction(input: { iconPath: string; canOpenInEditor: boolean; canOpenInBrowser: boolean; canOpenInPanel: boolean; }): boolean { return ( input.canOpenInBrowser && (shouldOpenMarkdownFileLinkInBrowserByDefault(input.iconPath) || (!input.canOpenInEditor && !input.canOpenInPanel)) ); } const EMPTY_MARKDOWN_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = []; const CODE_FENCE_LANGUAGE_REGEX = /(?:^|\s)language-([^\s]+)/; const WINDOWS_DRIVE_PATH_REGEX = /^[A-Za-z]:[\\/]/; const MAX_HIGHLIGHT_CACHE_ENTRIES = 500; const MAX_HIGHLIGHT_CACHE_MEMORY_BYTES = 50 * 1024 * 1024; interface MarkdownActionFailureContext { readonly operation: string; readonly target?: string; readonly format?: "markdown" | "csv"; readonly language?: string; readonly fenceTitle?: string; readonly copyTarget?: string; } function reportMarkdownActionFailure(context: MarkdownActionFailureContext, cause: unknown): void { console.error("[chat-markdown] action failed", context, cause); } const highlightedCodeCache = new LRUCache<string>( MAX_HIGHLIGHT_CACHE_ENTRIES, MAX_HIGHLIGHT_CACHE_MEMORY_BYTES, ); function findTaskListMarkerOffset(markdown: string, listItemStart: number): number | null { const firstLineEnd = markdown.indexOf("\n", listItemStart); const firstLine = markdown.slice( listItemStart, firstLineEnd === -1 ? markdown.length : firstLineEnd, ); const match = firstLine.match(/^(?:\s*(?:[-+*]|\d+[.)])\s+)(\[[ xX]\])/); if (!match?.[1]) return null; return listItemStart + firstLine.indexOf(match[1]); } /** * The default `1.25rem` marker gutter (`.chat-markdown ol`) fits markers up to * two characters wide. Once a marker reaches three characters (item 100+), * `list-style-position: outside` paints it wider than that gutter and clips * the leading character against the item's own overflow. Rather than widening * the gutter for every list, only lists whose widest marker is 3+ characters * get a wider `--list-gutter`. The width includes a negative marker's minus * sign. */ export function orderedListGutterStyle( itemCount: number, start: unknown, ): { "--list-gutter": string } | undefined { const parsedStart = Number.parseInt(String(start ?? 1), 10); const firstNumber = Number.isNaN(parsedStart) ? 1 : parsedStart; const lastNumber = firstNumber + Math.max(itemCount - 1, 0); const markerWidth = Math.max(String(firstNumber).length, String(lastNumber).length); if (markerWidth <= 2) return undefined; return { "--list-gutter": `${markerWidth + 1}ch` }; } type MarkdownHtmlAstNode = { type?: string; tagName?: string; properties?: Record<string, unknown>; children?: MarkdownHtmlAstNode[]; }; /** Preserve Windows drive paths through the protocol allowlist in rehype-sanitize. */ function rehypeNormalizeWindowsImageSrc() { return (tree: MarkdownHtmlAstNode) => { const visit = (node: MarkdownHtmlAstNode) => { const src = node.properties?.src; if ( node.type === "element" && node.tagName === "img" && typeof src === "string" && WINDOWS_DRIVE_PATH_REGEX.test(src) ) { node.properties = { ...node.properties, src: `file:///${src.replaceAll("\\", "/")}`, }; } node.children?.forEach(visit); }; visit(tree); }; } const CHAT_MARKDOWN_SANITIZE_SCHEMA = { ...defaultSchema, attributes: { ...defaultSchema.attributes, "*": (defaultSchema.attributes?.["*"] ?? []).filter((attribute) => attribute !== "title"), code: [...(defaultSchema.attributes?.code ?? []), "dataCodeMeta", "dataInlineCode"], blockquote: [...(defaultSchema.attributes?.blockquote ?? []), "dataAlert"], }, protocols: { ...defaultSchema.protocols, href: [...(defaultSchema.protocols?.href ?? []), "file"], src: [...(defaultSchema.protocols?.src ?? []), "file"], }, } satisfies Parameters<typeof rehypeSanitize>[0]; const CHAT_MARKDOWN_REMARK_PLUGINS = [ remarkGfm, remarkGithubAlerts, remarkNormalizeListItemIndentation, remarkPreserveCodeMeta, remarkNormalizeLinksAndTagInlineCode, ] satisfies NonNullable<ReactMarkdownOptions["remarkPlugins"]>; const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ remarkGfm, remarkGithubAlerts, remarkNormalizeListItemIndentation, remarkBreaks, remarkPreserveCodeMeta, remarkNormalizeLinksAndTagInlineCode, ] satisfies NonNullable<ReactMarkdownOptions["remarkPlugins"]>; const CHAT_MARKDOWN_REHYPE_PLUGINS = [ rehypeRaw, rehypeNormalizeWindowsImageSrc, [rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA], ] satisfies NonNullable<ReactMarkdownOptions["rehypePlugins"]>; /** GitHub's own five alert kinds, in its colors: the glyph names the urgency, the title says it. */ const GITHUB_ALERT_PRESENTATIONS: Record< string, { label: string; Icon: typeof InfoIcon; borderClassName: string; titleClassName: string } > = { note: { label: "Note", Icon: InfoIcon, borderClassName: "border-blue-500/70", titleClassName: "text-blue-600 dark:text-blue-400", }, tip: { label: "Tip", Icon: LightbulbIcon, borderClassName: "border-emerald-500/70", titleClassName: "text-emerald-600 dark:text-emerald-400", }, important: { label: "Important", Icon: MessageSquareWarningIcon, borderClassName: "border-purple-500/70", titleClassName: "text-purple-600 dark:text-purple-400", }, warning: { label: "Warning", Icon: TriangleAlertIcon, borderClassName: "border-amber-500/70", titleClassName: "text-amber-600 dark:text-amber-500", }, caution: { label: "Caution", Icon: OctagonAlertIcon, borderClassName: "border-red-500/70", titleClassName: "text-red-600 dark:text-red-400", }, }; function extractFenceLanguage(className: string | undefined): string { const match = className?.match(CODE_FENCE_LANGUAGE_REGEX); const raw = match?.[1] ?? "text"; // Shiki doesn't bundle a gitignore grammar; ini is a close match (#685) return raw === "gitignore" ? "ini" : raw; } const FENCE_TITLE_ATTR_REGEX = /(?:^|\s)(?:title|file(?:name)?)=(?:"([^"]+)"|'([^']+)'|(\S+))/i; const FENCE_FILENAME_TOKEN_REGEX = /^[\w@][\w@./-]*\.[A-Za-z0-9]+$/; /** Pulls a filename out of fence meta: ```ts title="x.ts" / ```ts src/main.ts */ function extractFenceTitle(meta: string | undefined): string | null { if (!meta) return null; const attrMatch = FENCE_TITLE_ATTR_REGEX.exec(meta); const attrTitle = attrMatch?.[1] ?? attrMatch?.[2] ?? attrMatch?.[3]; if (attrTitle) return attrTitle; return meta.split(/\s+/).find((candidate) => FENCE_FILENAME_TOKEN_REGEX.test(candidate)) ?? null; } function extractPreCodeMeta(node: unknown): string | undefined { const children = ( node as | { children?: Array<{ type?: string; tagName?: string; data?: { meta?: unknown }; properties?: { dataCodeMeta?: unknown }; }>; } | undefined )?.children; const codeNode = children?.find((child) => child?.type === "element" && child.tagName === "code"); const meta = codeNode?.properties?.dataCodeMeta ?? codeNode?.data?.meta; return typeof meta === "string" && meta.trim().length > 0 ? meta.trim() : undefined; } type MarkdownAstNode = { type?: string; meta?: unknown; url?: string; data?: { hProperties?: Record<string, unknown>; }; children?: MarkdownAstNode[]; }; function remarkPreserveCodeMeta() { return (tree: MarkdownAstNode) => { const visit = (node: MarkdownAstNode) => { if (node.type === "code" && typeof node.meta === "string" && node.meta.trim().length > 0) { node.data = { ...node.data, hProperties: { ...node.data?.hProperties, dataCodeMeta: node.meta.trim(), }, }; } node.children?.forEach(visit); }; visit(tree); }; } /** * Preserve Windows drive links as allowed `file:` URLs before sanitization. * The same traversal tags inline code while it can still be distinguished * from fenced code. Code inside links stays untagged to avoid nested anchors. */ function remarkNormalizeLinksAndTagInlineCode() { return (tree: MarkdownAstNode) => { const visit = (node: MarkdownAstNode, insideLink: boolean) => { if ( (node.type === "link" || node.type === "definition") && typeof node.url === "string" && WINDOWS_DRIVE_PATH_REGEX.test(node.url) ) { node.url = `file:///${node.url.replaceAll("\\", "/")}`; } if (node.type === "inlineCode" && !insideLink) { node.data = { ...node.data, hProperties: { ...node.data?.hProperties, dataInlineCode: "", }, }; } const childInsideLink = insideLink || node.type === "link" || node.type === "linkReference"; node.children?.forEach((child) => visit(child, childInsideLink)); }; visit(tree, false); }; } function nodeToPlainText(node: ReactNode): string { if (typeof node === "string" || typeof node === "number") { return String(node); } if (Array.isArray(node)) { return node.map((child) => nodeToPlainText(child)).join(""); } if (isValidElement<{ children?: ReactNode }>(node)) { return nodeToPlainText(node.props.children); } return ""; } function extractCodeBlock( children: ReactNode, ): { className: string | undefined; code: string } | null { const childNodes = Children.toArray(children); if (childNodes.length !== 1) { return null; } const onlyChild = childNodes[0]; if ( !isValidElement<{ className?: string; children?: ReactNode; node?: { tagName?: string } }>( onlyChild, ) ) { return null; } // With a custom `code` component the child's type is that component, not // the "code" tag — the hast node react-markdown attaches still names it. if (onlyChild.type !== "code" && onlyChild.props.node?.tagName !== "code") { return null; } return { className: onlyChild.props.className, code: nodeToPlainText(onlyChild.props.children), }; } function createHighlightCacheKey(code: string, language: string, themeName: DiffThemeName): string { return `${fnv1a32(code).toString(36)}:${code.length}:${language}:${themeName}`; } function estimateHighlightedSize(html: string, code: string): number { return Math.max(html.length * 2, code.length * 3); } function readInitialWordWrapSetting(): boolean { return getClientSettings().wordWrap; } function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) { const containerRef = useRef<HTMLDivElement | null>(null); const tableRef = useRef<HTMLTableElement | null>(null); const [expanded, setExpanded] = useState(readInitialWordWrapSetting); const [copied, setCopied] = useState(false);