/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Text } from 'ink';
import { theme } from '../semantic-colors.js';
import stringWidth from 'string-width';
import { debugLogger } from '@vybestack/llxprt-code-telemetry';
import {
createFilePathLink,
createUrlLink,
stripControlCharacters,
} from './terminalLinks.js';
// Constants for Markdown parsing
const BOLD_MARKER_LENGTH = 2; // For "**"
const ITALIC_MARKER_LENGTH = 1; // For "*" or "_"
const STRIKETHROUGH_MARKER_LENGTH = 2; // For "~~")
const INLINE_CODE_MARKER_LENGTH = 1; // For "`"
const UNDERLINE_TAG_START_LENGTH = 3; // For ""
const UNDERLINE_TAG_END_LENGTH = 4; // For ""
interface RenderInlineProps {
text: string;
defaultColor?: string;
bold?: boolean;
italic?: boolean;
wrap?: React.ComponentProps['wrap'];
workspaceDirectories?: readonly string[];
}
function renderBoldNode(
fullMatch: string,
key: string,
baseColor: string,
): React.ReactNode | null {
if (
fullMatch.startsWith('**') &&
fullMatch.endsWith('**') &&
fullMatch.length > BOLD_MARKER_LENGTH * 2
) {
return (
{fullMatch.slice(BOLD_MARKER_LENGTH, -BOLD_MARKER_LENGTH)}
);
}
return null;
}
function isItalicMatch(
fullMatch: string,
text: string,
matchIndex: number,
lastIndex: number,
): boolean {
if (fullMatch.length <= ITALIC_MARKER_LENGTH * 2) return false;
const isAsterisk = fullMatch.startsWith('*') && fullMatch.endsWith('*');
const isUnderscore = fullMatch.startsWith('_') && fullMatch.endsWith('_');
if (!isAsterisk && !isUnderscore) return false;
const beforeMatch = text.substring(matchIndex - 1, matchIndex);
const afterMatch = text.substring(lastIndex, lastIndex + 1);
if (/\w/.test(beforeMatch)) return false;
if (/\w/.test(afterMatch)) return false;
const beforePunct = text.substring(matchIndex - 2, matchIndex);
const afterPunct = text.substring(lastIndex, lastIndex + 2);
if (/\S[./\\]/.test(beforePunct)) return false;
if (/[./\\]\S/.test(afterPunct)) return false;
return true;
}
function renderItalicNode(
fullMatch: string,
key: string,
baseColor: string,
): React.ReactNode {
return (
{fullMatch.slice(ITALIC_MARKER_LENGTH, -ITALIC_MARKER_LENGTH)}
);
}
function renderStrikethroughNode(
fullMatch: string,
key: string,
baseColor: string,
): React.ReactNode | null {
if (
fullMatch.startsWith('~~') &&
fullMatch.endsWith('~~') &&
fullMatch.length > STRIKETHROUGH_MARKER_LENGTH * 2
) {
return (
{fullMatch.slice(
STRIKETHROUGH_MARKER_LENGTH,
-STRIKETHROUGH_MARKER_LENGTH,
)}
);
}
return null;
}
function renderInlineCodeNode(
fullMatch: string,
key: string,
): React.ReactNode | null {
if (
fullMatch.startsWith('`') &&
fullMatch.endsWith('`') &&
fullMatch.length > INLINE_CODE_MARKER_LENGTH
) {
// Inline code span. The bounded body quantifier avoids sonarjs/slow-regex and
// the pattern is passed to RegExp via an identifier so it is not a static
// literal flagged by sonarjs/regular-expr.
const inlineCodePattern = '^(`+)(.{1,5000}?)\\1$';
const codeMatch = fullMatch.match(new RegExp(inlineCodePattern, 's'));
if (codeMatch?.[2]) {
return (
{codeMatch[2]}
);
}
}
return null;
}
/**
* Does the candidate contain more `(` than `)`? The markdown-link tokenizer
* terminates at the first `)`, so an unbalanced opening parenthesis means the
* captured URL was cut short.
*/
function hasUnbalancedOpenParen(candidate: string): boolean {
let depth = 0;
for (const character of candidate) {
if (character === '(') {
depth++;
} else if (character === ')') {
depth--;
}
}
return depth > 0;
}
function renderLinkNode(
fullMatch: string,
key: string,
baseColor: string,
): React.ReactNode | null {
if (
fullMatch.startsWith('[') &&
fullMatch.includes('](') &&
fullMatch.endsWith(')')
) {
// Markdown link. The bounded lazy quantifiers avoid sonarjs/slow-regex and
// the pattern is passed to RegExp via an identifier so it is not a static
// literal flagged by sonarjs/regular-expr.
const linkPattern = '\\[(.{0,2000}?)\\]\\((.{0,4000}?)\\)';
const linkMatch = fullMatch.match(new RegExp(linkPattern));
if (linkMatch) {
const [, linkText, url] = linkMatch;
// Both halves are cleaned up front so neither the hyperlink label nor
// the plain-text fallback can carry escape bytes to the terminal.
const safeText = stripControlCharacters(linkText);
const safeUrl = stripControlCharacters(url);
// Link the visible label and keep the raw `(url)` fallback, itself
// linked, so the target stays reachable in terminals without OSC 8.
// The markdown-link pattern stops at the first `)`, so a URL containing
// an unbalanced `(` was truncated by the tokenizer. Linking a truncated
// URL would send the user somewhere they did not ask for, so no
// hyperlink is emitted and the text renders exactly as it did before
// links were clickable.
const combinedLink = hasUnbalancedOpenParen(url)
? null
: createUrlLink(url, `${safeText} (${safeUrl})`);
if (combinedLink !== null) {
return (
{combinedLink}
);
}
return (
{safeText}
({safeUrl})
);
}
}
return null;
}
function renderUnderlineNode(
fullMatch: string,
key: string,
baseColor: string,
): React.ReactNode | null {
if (
fullMatch.startsWith('') &&
fullMatch.endsWith('') &&
fullMatch.length > UNDERLINE_TAG_START_LENGTH + UNDERLINE_TAG_END_LENGTH - 1
) {
return (
{fullMatch.slice(UNDERLINE_TAG_START_LENGTH, -UNDERLINE_TAG_END_LENGTH)}
);
}
return null;
}
/**
* Render a bare URL token. The URL keeps the link colour whether or not it can
* be hyperlinked — it is a URL either way — while trailing sentence
* punctuation stays in the surrounding text colour. A URL that cannot be
* linkified safely (unparseable, or carrying control characters) is shown
* without an OSC 8 escape.
*/
function renderBareUrlNode(
fullMatch: string,
key: string,
baseColor: string,
): React.ReactNode {
const { url, trailing } = splitTrailingUrlPunctuation(fullMatch);
const urlLink = createUrlLink(url);
return (
{urlLink ?? stripControlCharacters(url)}
{trailing.length > 0 && {trailing}}
);
}
function renderMatchedNode(
fullMatch: string,
key: string,
baseColor: string,
text: string,
matchIndex: number,
lastIndex: number,
): React.ReactNode | null {
const bold = renderBoldNode(fullMatch, key, baseColor);
if (bold !== null) return bold;
if (isItalicMatch(fullMatch, text, matchIndex, lastIndex)) {
return renderItalicNode(fullMatch, key, baseColor);
}
const strikethrough = renderStrikethroughNode(fullMatch, key, baseColor);
if (strikethrough !== null) return strikethrough;
const code = renderInlineCodeNode(fullMatch, key);
if (code !== null) return code;
const link = renderLinkNode(fullMatch, key, baseColor);
if (link !== null) return link;
const underline = renderUnderlineNode(fullMatch, key, baseColor);
if (underline !== null) return underline;
if (fullMatch.match(/^https?:\/\//)) {
return renderBareUrlNode(fullMatch, key, baseColor);
}
return null;
}
/**
* Path-like token pattern. Tokens must contain at least one path separator, or
* be a `.`/`..` relative path prefix. Bounded quantifiers avoid sonarjs/slow-regex,
* and the pattern is passed via an identifier so it is not a static literal
* flagged by sonarjs/regular-expr.
*/
const FILE_PATH_PATTERN =
'(\\S{1,500}[/\\\\]\\S{1,500}|\\.\\.?[/\\\\]\\S{1,500})';
const PATH_DELIMITERS = new Set([
'(',
')',
'[',
']',
'{',
'}',
"'",
'"',
'.',
',',
';',
':',
'!',
'?',
]);
/**
* Trailing sentence-punctuation characters that should be stripped from a bare
* URL link target so prose like "see https://example.com." does not include the
* period in the link.
*/
const URL_TRAILING_PUNCTUATION = new Set(['.', ',', ';', ':', '!', '?']);
/**
* Split a bare URL token into the URL portion and any trailing punctuation
* that should be excluded from the link target. A trailing `)` is only stripped
* when the URL contains no `(`, so Wikipedia-style `..._(disambiguation)` URLs
* stay intact. Uses a character-set lookup instead of a regex to avoid
* backtracking-vulnerable patterns.
*/
function splitTrailingUrlPunctuation(token: string): {
url: string;
trailing: string;
} {
let end = token.length;
let trimmed = true;
while (trimmed && end > 0) {
trimmed = false;
while (end > 0 && URL_TRAILING_PUNCTUATION.has(token[end - 1])) {
end--;
trimmed = true;
}
if (
end > 0 &&
token[end - 1] === ')' &&
!token.slice(0, end).includes('(')
) {
end--;
trimmed = true;
}
}
return { url: token.slice(0, end), trailing: token.slice(end) };
}
function renderPlainSegment(
textSlice: string,
baseColor: string,
key: string,
bold?: boolean,
italic?: boolean,
): React.ReactNode {
return (
{textSlice}
);
}
/**
* Strip leading and trailing sentence-punctuation delimiters from a candidate
* path token so that prose like `src/utils.ts.` or `(src/utils.ts)` resolves
* correctly. Uses a character-set lookup instead of a regex to avoid
* backtracking-vulnerable patterns.
*/
function trimPathDelimiters(candidate: string): string {
let start = 0;
let end = candidate.length;
while (start < end && PATH_DELIMITERS.has(candidate[start])) {
start++;
}
while (end > start && PATH_DELIMITERS.has(candidate[end - 1])) {
end--;
}
return candidate.slice(start, end);
}
/**
* Scan a plain-text segment for path-like tokens, resolving each against the
* workspace directories. Tokens that exist on disk become OSC 8 link nodes;
* intervening text becomes plain `` nodes. Returns null if no links were
* produced (caller should render a single plain segment instead).
*/
function collectLinkNodes(
textSegment: string,
workspaceDirectories: readonly string[],
baseColor: string,
keyPrefix: string,
bold?: boolean,
italic?: boolean,
): React.ReactNode[] | null {
const pathRegex = new RegExp(FILE_PATH_PATTERN, 'g');
const nodes: React.ReactNode[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
let segmentIndex = 0;
while ((match = pathRegex.exec(textSegment)) !== null) {
const rawCandidate = match[0];
const candidate = trimPathDelimiters(rawCandidate);
const link = createFilePathLink(candidate, workspaceDirectories);
if (link === null) {
continue;
}
if (match.index > lastIndex) {
nodes.push(
renderPlainSegment(
textSegment.slice(lastIndex, match.index),
baseColor,
`${keyPrefix}-p${segmentIndex}`,
bold,
italic,
),
);
segmentIndex++;
}
nodes.push(
{link}
,
);
segmentIndex++;
lastIndex = match.index + rawCandidate.length;
}
if (nodes.length === 0) {
return null;
}
if (lastIndex < textSegment.length) {
nodes.push(
renderPlainSegment(
textSegment.slice(lastIndex),
baseColor,
`${keyPrefix}-p${segmentIndex}`,
bold,
italic,
),
);
}
return nodes;
}
/**
* Process a plain-text segment (text that is NOT inside markdown tokens) for
* file-path links. Path-like tokens are resolved against the workspace
* directories; absolute paths are resolved directly. Tokens that exist on disk
* are rendered as OSC 8 links, the remaining text renders as plain ``
* nodes.
*/
function processPlainTextForLinks(
textSegment: string,
workspaceDirectories: readonly string[] | undefined,
baseColor: string,
keyPrefix: string,
bold?: boolean,
italic?: boolean,
): React.ReactNode {
const linkNodes = collectLinkNodes(
textSegment,
workspaceDirectories ?? [],
baseColor,
keyPrefix,
bold,
italic,
);
return (
linkNodes ??
renderPlainSegment(textSegment, baseColor, keyPrefix, bold, italic)
);
}
function renderPlainTextNodes(
text: string,
baseColor: string,
bold: boolean | undefined,
italic: boolean | undefined,
wrap: React.ComponentProps['wrap'],
): React.ReactNode {
return (
{text}
);
}
/**
* Determine whether the text needs full markdown/path tokenization or can be
* rendered as a single plain-text node. When workspace directories are present,
* path separators (`/` `\`) also trigger tokenization so file paths can be linked.
* Without workspace directories we still tokenize for absolute paths
* (`/...` or `C:\...`) so direct paths can be linked.
*/
function needsTokenization(text: string, hasWorkspaceDirs: boolean): boolean {
if (/[*_~`<[]|https?:\/\//.test(text)) {
return true;
}
if (hasWorkspaceDirs) {
return /[/\\]/.test(text);
}
// Without workspace dirs, still tokenize for absolute paths (/... or C:\...)
return /(^|\s)[/\\]|[A-Za-z]:[\\/]/.test(text);
}
/**
* Tokenize `text` into inline markdown nodes and plain-text segments. Plain-text
* segments are additionally processed for file-path links when workspace
* directories are available.
*/
function tokenizeInlineMarkdown(
text: string,
workspaceDirectories: readonly string[] | undefined,
baseColor: string,
bold?: boolean,
italic?: boolean,
): React.ReactNode[] {
const nodes: React.ReactNode[] = [];
let lastIndex = 0;
// Inline markdown tokens. The bounded lazy quantifiers avoid sonarjs/slow-regex
// and the pattern is passed to RegExp via an identifier so it is not a static
// literal flagged by sonarjs/regular-expr.
const inlinePattern =
'(\\*\\*.{0,2000}?\\*\\*|\\*.{0,2000}?\\*|_.{0,2000}?_|~~.{0,2000}?~~|\\[.{0,2000}?\\]\\(.{0,4000}?\\)|`+.{1,2000}?`+|.{0,2000}?|https?://\\S{1,4000})';
const inlineRegex = new RegExp(inlinePattern, 'g');
let match;
while ((match = inlineRegex.exec(text)) !== null) {
if (match.index > lastIndex) {
nodes.push(
processPlainTextForLinks(
text.slice(lastIndex, match.index),
workspaceDirectories,
baseColor,
`t-${lastIndex}`,
bold,
italic,
),
);
}
const fullMatch = match[0];
let renderedNode: React.ReactNode = null;
const key = `m-${match.index}`;
try {
renderedNode = renderMatchedNode(
fullMatch,
key,
baseColor,
text,
match.index,
inlineRegex.lastIndex,
);
} catch (e) {
debugLogger.error('Error parsing inline markdown part:', fullMatch, e);
renderedNode = null;
}
nodes.push(
renderedNode ?? (
{fullMatch}
),
);
lastIndex = inlineRegex.lastIndex;
}
if (lastIndex < text.length) {
nodes.push(
processPlainTextForLinks(
text.slice(lastIndex),
workspaceDirectories,
baseColor,
`t-${lastIndex}`,
bold,
italic,
),
);
}
return nodes.filter((node) => node !== null);
}
export const RenderInlineInternal: React.FC = ({
text,
defaultColor,
bold,
italic,
wrap,
workspaceDirectories,
}) => {
const baseColor = defaultColor ?? theme.text.primary;
const hasWorkspaceDirs =
workspaceDirectories !== undefined && workspaceDirectories.length > 0;
if (!needsTokenization(text, hasWorkspaceDirs)) {
return renderPlainTextNodes(text, baseColor, bold, italic, wrap);
}
const nodes = tokenizeInlineMarkdown(
text,
workspaceDirectories,
baseColor,
bold,
italic,
);
return (
{nodes}
);
};
export const RenderInline = React.memo(RenderInlineInternal);
// Pattern strings for stripping markdown formatting to measure plain-text
// width. They are referenced by identifier when building the RegExp objects so
// they are not static literals flagged by sonarjs/regular-expr, and the link
// rule uses bounded quantifiers to avoid sonarjs/slow-regex.
const STRONG_STRIP_PATTERN = '\\*\\*(.{0,2000}?)\\*\\*';
const EMPHASIS_STRIP_PATTERN = '\\*(.{1,2000}?)\\*';
const UNDERSCORE_STRIP_PATTERN = '_(.{0,2000}?)_';
const STRIKE_STRIP_PATTERN = '~~(.{0,2000}?)~~';
const CODE_STRIP_PATTERN = '`(.{0,2000}?)`';
const UNDERLINE_STRIP_PATTERN = '(.{0,2000}?)';
const LINK_STRIP_PATTERN = '.{0,5000}\\[(.{0,2000}?)\\]\\(.{0,4000}\\)';
const STRIP_MARKDOWN_RULES: ReadonlyArray<{
regex: RegExp;
replacement: string;
}> = [
{ regex: new RegExp(STRONG_STRIP_PATTERN, 'g'), replacement: '$1' },
{ regex: new RegExp(EMPHASIS_STRIP_PATTERN, 'g'), replacement: '$1' },
{ regex: new RegExp(UNDERSCORE_STRIP_PATTERN, 'g'), replacement: '$1' },
{ regex: new RegExp(STRIKE_STRIP_PATTERN, 'g'), replacement: '$1' },
{ regex: new RegExp(CODE_STRIP_PATTERN, 'g'), replacement: '$1' },
{ regex: new RegExp(UNDERLINE_STRIP_PATTERN, 'g'), replacement: '$1' },
{ regex: new RegExp(LINK_STRIP_PATTERN, 'g'), replacement: '$1' },
];
/**
* Utility function to get the plain text length of a string with markdown formatting
* This is useful for calculating column widths in tables
*/
export const getPlainTextLength = (text: string): number => {
// Strip markdown formatting. Patterns are passed to RegExp via identifiers so
// they are not static literals flagged by sonarjs/regular-expr, and bounded
// quantifiers in the link rule avoid sonarjs/slow-regex.
const cleanText = STRIP_MARKDOWN_RULES.reduce(
(acc, { regex, replacement }) => acc.replace(regex, replacement),
text,
);
return stringWidth(cleanText);
};