import { useThemeColors } from "../../theme/theme-context"; import { Box, StyledText, Text, TextAttributes, useUiCapabilities } from "../../ui"; import { wrapTextLines } from "../../utils/text-wrap"; export interface ProseRun { text: string; /** A number the eye should land on: money, percentages, quantities. */ figure: boolean; } /** * Money, percentages and quantities with a unit, plus bare numbers except * years, each with its sign. Ordinals and fiscal labels (Q2, FY26, 2Q) are * left alone. */ const FIGURE_PATTERN = /[+\-\u2212]?(?:[$€£¥]\s?\d[\d,]*(?:\.\d+)?(?:\s?(?:trillion|billion|million|thousand|bn|mn|k|m|b)\b)?|\b\d[\d,]*(?:\.\d+)?\s?(?:%|percent(?:age points?)?|bps|basis points|x\b|trillion|billion|million|thousand)|\b\d[\d,]*(?:\.\d+)?\b)/gi; const YEAR_PATTERN = /^(?:19|20)\d{2}$/; /** "Sep 01", "Feb 24, 2026", "Sep 01, 26": the day and year are not figures. */ const MONTH_DATE_PATTERN = /\b(?:Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|June?|July?|Aug(?:ust)?|Sep(?:t(?:ember)?)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)\.?\s+\d{1,2}(?:st|nd|rd|th)?\b(?:,\s*\d{2}(?:\d{2})?\b)?/g; const isWordChar = (char: string | undefined) => char !== undefined && /[\p{L}\p{N}]/u.test(char); const isJoiner = (char: string | undefined) => char === "-" || char === "/"; /** * Digits joined to a word by a hyphen or slash are part of a code or a date * (2025-10-31, 10-K, 8-K/A, 0000320193-24-000123), not a figure. So is a * zero-padded number, an id rather than a quantity. */ function isCodeDigits(text: string, start: number, end: number, digits: string): boolean { return /^0\d/.test(digits) || (isJoiner(text[start - 1]) && isWordChar(text[start - 2])) || (/\d$/.test(digits) && isJoiner(text[end]) && isWordChar(text[end + 1])); } export function splitFigures(text: string): ProseRun[] { const runs: ProseRun[] = []; const dates = [...text.matchAll(MONTH_DATE_PATTERN)] .map((match) => [match.index ?? 0, (match.index ?? 0) + match[0].length] as const); let last = 0; for (const match of text.matchAll(FIGURE_PATTERN)) { const start = match.index ?? 0; const value = match[0]; const end = start + value.length; const signed = /^[+\-\u2212]/.test(value); const unsigned = signed ? value.slice(1) : value; // A sign glued to the word before it is a hyphen or a range: 10-15, A-1. if (signed && (isWordChar(text[start - 1]) || isJoiner(text[start - 1]))) continue; if (YEAR_PATTERN.test(unsigned)) continue; if (dates.some(([dateStart, dateEnd]) => start < dateEnd && end > dateStart)) continue; // A currency amount keeps its own prefix (US$5); only plain digits can be codes. if (/^\d/.test(unsigned) && isCodeDigits(text, start, end, unsigned)) continue; if (start > last) runs.push({ text: text.slice(last, start), figure: false }); runs.push({ text: value, figure: true }); last = end; } if (last < text.length) runs.push({ text: text.slice(last), figure: false }); return runs; } export interface ProseProps { text: string; width: number; color?: string; /** * Numbers set in bold and brightened. Turn it off for text that carries its * own meaning in its colour, such as an error, where a brightened figure * would read as a separate thing. */ figures?: boolean; /** Applied to the whole paragraph, e.g. bold for a question. */ attributes?: number; /** Put before the first line; later lines are indented by its width. */ prefix?: string; prefixColor?: string; } const NATIVE_STRETCH_STYLE = { minWidth: 0 }; const NATIVE_TEXT_STYLE = { display: "block" }; /** Figures set in bold, the rest in the given colour. */ function styledRuns( text: string, color: string, figureColor: string | null, attributes = 0, ): StyledText { if (!figureColor) return new StyledText([{ text, fg: color, attributes }]); return new StyledText( splitFigures(text).map((run) => ({ text: run.text, fg: run.figure ? figureColor : color, attributes: run.figure ? attributes | TextAttributes.BOLD : attributes, })), ); } /** * A paragraph with figures in bold. The terminal renderer does not wrap on * its own, so lines are pre-wrapped and styled one at a time; desktop * chrome wraps the styled paragraph itself. `indent` is applied to every * line after the first, for bullets. */ export function Prose({ text, width, color, figures = true, attributes = 0, prefix = "", prefixColor, }: ProseProps) { const colors = useThemeColors(); const { nativePaneChrome } = useUiCapabilities(); const foreground = color ?? colors.text; const figureColor = figures ? colors.textBright : null; const prefixForeground = prefixColor ?? colors.textDim; if (!text.trim()) return null; if (nativePaneChrome) { return ( {prefix ? ( {prefix} ) : null} ); } const indent = " ".repeat(prefix.length); return wrapTextLines(text, Math.max(8, width - prefix.length)).map( (line, index) => ( {prefix ? ( {index === 0 ? prefix : indent} ) : null} ), ); }