/** * Token-to-ANSI-string formatter for markdown tokens. * * Adapted from Claude Code's utils/markdown.ts formatToken. Simplified: * - No syntax highlighting (code blocks render as plain text) * - No theme system (uses chalk directly for consistent terminal styling) * - No Claude-specific stripPromptXMLTags * - No hyperlink linkification of issue references * - Table rendering included inline (no separate component needed) */ import chalk from 'chalk' import stripAnsi from 'strip-ansi' import type { Token, Tokens } from 'marked' import { stringWidth } from '../internal/string-width.js' // Use \n unconditionally — os.EOL is \r\n on Windows, and the extra \r // breaks character-to-segment mapping in applyStylesToWrappedText, // causing styled text to shift right. const EOL = '\n' const BLOCKQUOTE_BAR = '\u2502' // │ /** * Format a single marked token into an ANSI string. * * @param token - The marked token to format * @param listDepth - Current nesting depth for lists (0 = top-level) * @param orderedListNumber - If inside an ordered list, the current item number; null for unordered * @param parent - Parent token for context-dependent rendering (e.g. text inside links) */ export function formatToken( token: Token, listDepth = 0, orderedListNumber: number | null = null, parent: Token | null = null, ): string { switch (token.type) { case 'blockquote': { const inner = (token.tokens ?? []) .map(t => formatToken(t, 0, null, null)) .join('') const bar = chalk.dim(BLOCKQUOTE_BAR) return inner .split(EOL) .map(line => stripAnsi(line).trim() ? `${bar} ${chalk.italic(line)}` : line, ) .join(EOL) } case 'code': { // No syntax highlighting — render as plain text with dim styling return chalk.dim(token.text) + EOL } case 'codespan': { // Inline code — render with cyan (a common terminal convention) return chalk.cyan(token.text) } case 'em': return chalk.italic( (token.tokens ?? []) .map(t => formatToken(t, 0, null, parent)) .join(''), ) case 'strong': return chalk.bold( (token.tokens ?? []) .map(t => formatToken(t, 0, null, parent)) .join(''), ) case 'heading': switch (token.depth) { case 1: return ( chalk.bold.italic.underline( (token.tokens ?? []) .map(t => formatToken(t, 0, null, null)) .join(''), ) + EOL + EOL ) case 2: return ( chalk.bold( (token.tokens ?? []) .map(t => formatToken(t, 0, null, null)) .join(''), ) + EOL + EOL ) default: return ( chalk.bold( (token.tokens ?? []) .map(t => formatToken(t, 0, null, null)) .join(''), ) + EOL + EOL ) } case 'hr': return '---' case 'image': return token.href case 'link': { if (token.href.startsWith('mailto:')) { const email = token.href.replace(/^mailto:/, '') return email } const linkText = (token.tokens ?? []) .map(t => formatToken(t, 0, null, token)) .join('') const plainLinkText = stripAnsi(linkText) if (plainLinkText && plainLinkText !== token.href) { // Show as "text (url)" in terminals without hyperlink detection return `${linkText} ${chalk.dim(`(${token.href})`)}` } return chalk.underline(token.href) } case 'list': { return token.items .map((item: Token, index: number) => formatToken( item, listDepth, token.ordered ? token.start + index : null, token, ), ) .join('') } case 'list_item': return (token.tokens ?? []) .map( t => `${' '.repeat(listDepth)}${formatToken(t, listDepth + 1, orderedListNumber, token)}`, ) .join('') case 'paragraph': return ( (token.tokens ?? []) .map(t => formatToken(t, 0, null, null)) .join('') + EOL ) case 'space': return EOL case 'br': return EOL case 'text': if (parent?.type === 'list_item') { const bullet = orderedListNumber === null ? '-' : `${getListNumber(listDepth, orderedListNumber)}.` const content = token.tokens ? token.tokens .map(t => formatToken(t, listDepth, orderedListNumber, token), ) .join('') : token.text return `${bullet} ${content}${EOL}` } return token.text case 'table': { return formatTable(token as Tokens.Table) } case 'escape': return token.text case 'def': case 'del': case 'html': return '' } return '' } /** * Format a markdown table token into an aligned ANSI string. */ function formatTable(tableToken: Tokens.Table): string { function getDisplayText(tokens: Token[] | undefined): string { return stripAnsi( tokens?.map(t => formatToken(t, 0, null, null)).join('') ?? '', ) } // Determine column widths based on displayed content const columnWidths = tableToken.header.map((header, index) => { let maxWidth = stringWidth(getDisplayText(header.tokens)) for (const row of tableToken.rows) { const cellLength = stringWidth(getDisplayText(row[index]?.tokens)) maxWidth = Math.max(maxWidth, cellLength) } return Math.max(maxWidth, 3) }) // Format header row let output = '| ' tableToken.header.forEach((header, index) => { const content = header.tokens ?.map(t => formatToken(t, 0, null, null)) .join('') ?? '' const displayText = getDisplayText(header.tokens) const width = columnWidths[index]! const align = tableToken.align?.[index] output += padAligned(content, stringWidth(displayText), width, align) + ' | ' }) output = output.trimEnd() + EOL // Separator row output += '|' columnWidths.forEach(width => { output += '-'.repeat(width + 2) + '|' }) output += EOL // Data rows tableToken.rows.forEach(row => { output += '| ' row.forEach((cell, index) => { const content = cell.tokens ?.map(t => formatToken(t, 0, null, null)) .join('') ?? '' const displayText = getDisplayText(cell.tokens) const width = columnWidths[index]! const align = tableToken.align?.[index] output += padAligned(content, stringWidth(displayText), width, align) + ' | ' }) output = output.trimEnd() + EOL }) return output + EOL } /** * Pad content to targetWidth according to alignment. * displayWidth is the visible width (without ANSI codes). */ function padAligned( content: string, displayWidth: number, targetWidth: number, align: 'left' | 'center' | 'right' | null | undefined, ): string { const padding = Math.max(0, targetWidth - displayWidth) if (align === 'center') { const leftPad = Math.floor(padding / 2) return ' '.repeat(leftPad) + content + ' '.repeat(padding - leftPad) } if (align === 'right') { return ' '.repeat(padding) + content } return content + ' '.repeat(padding) } function numberToLetter(n: number): string { let result = '' while (n > 0) { n-- result = String.fromCharCode(97 + (n % 26)) + result n = Math.floor(n / 26) } return result } const ROMAN_VALUES: ReadonlyArray<[number, string]> = [ [1000, 'm'], [900, 'cm'], [500, 'd'], [400, 'cd'], [100, 'c'], [90, 'xc'], [50, 'l'], [40, 'xl'], [10, 'x'], [9, 'ix'], [5, 'v'], [4, 'iv'], [1, 'i'], ] function numberToRoman(n: number): string { let result = '' for (const [value, numeral] of ROMAN_VALUES) { while (n >= value) { result += numeral n -= value } } return result } function getListNumber(listDepth: number, orderedListNumber: number): string { switch (listDepth) { case 0: case 1: return orderedListNumber.toString() case 2: return numberToLetter(orderedListNumber) case 3: return numberToRoman(orderedListNumber) default: return orderedListNumber.toString() } }