fix(tui): render inline markdown formatting with ANSI codes

Block-level renderer methods (paragraph, heading, blockquote, list) were
using raw token.text instead of this.parser.parseInline(tokens), causing
bold, italic, and inline code to never render. Add table renderer with
aligned columns and box-drawing separators. Remove unused marked-terminal
dependency (incompatible with marked v17).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
William Valentin
2026-02-10 11:29:57 -08:00
parent ff03f74404
commit 4cc29f534a
4 changed files with 150 additions and 131 deletions
+86 -5
View File
@@ -7,8 +7,37 @@ describe('renderMarkdown', () => {
expect(result).toContain('Hello world');
});
it('renders bold text', () => {
it('renders bold text with ANSI bold code', () => {
const result = renderMarkdown('This is **bold** text');
expect(result).toContain('\x1b[1m');
expect(result).toContain('bold');
expect(result).toContain('\x1b[0m');
});
it('renders italic text with ANSI italic code', () => {
const result = renderMarkdown('This is *italic* text');
expect(result).toContain('\x1b[3m');
expect(result).toContain('italic');
expect(result).toContain('\x1b[0m');
});
it('renders inline code with ANSI cyan code', () => {
const result = renderMarkdown('Use `console.log()` for debugging');
expect(result).toContain('\x1b[36m');
expect(result).toContain('console.log()');
expect(result).toContain('\x1b[0m');
});
it('renders headings with bold + underline', () => {
const result = renderMarkdown('# My Heading');
expect(result).toContain('\x1b[1m\x1b[4m');
expect(result).toContain('My Heading');
expect(result).toContain('\x1b[0m');
});
it('renders heading with inline formatting', () => {
const result = renderMarkdown('## A **bold** heading');
expect(result).toContain('\x1b[1m');
expect(result).toContain('bold');
});
@@ -18,14 +47,66 @@ describe('renderMarkdown', () => {
expect(result).toContain('x');
});
it('renders inline code', () => {
const result = renderMarkdown('Use `console.log()` for debugging');
expect(result).toContain('console.log()');
it('renders code blocks with unknown language without crashing', () => {
const result = renderMarkdown('```madeuplang\nsome code\n```');
expect(result).toContain('some code');
});
it('renders lists', () => {
it('renders unordered lists', () => {
const result = renderMarkdown('- Item 1\n- Item 2');
expect(result).toContain('•');
expect(result).toContain('Item 1');
expect(result).toContain('Item 2');
});
it('renders inline formatting inside list items', () => {
const result = renderMarkdown('- **bold** item\n- `code` item');
expect(result).toContain('\x1b[1m');
expect(result).toContain('bold');
expect(result).toContain('\x1b[36m');
expect(result).toContain('code');
});
it('renders blockquotes with │ prefix', () => {
const result = renderMarkdown('> This is a quote');
expect(result).toContain('│');
expect(result).toContain('This is a quote');
});
it('renders inline formatting inside blockquotes', () => {
const result = renderMarkdown('> A **bold** quote');
expect(result).toContain('│');
expect(result).toContain('\x1b[1m');
expect(result).toContain('bold');
});
it('renders tables with │ separator', () => {
const result = renderMarkdown('| Col A | Col B |\n|-------|-------|\n| 1 | 2 |\n| 3 | 4 |');
expect(result).toContain('│');
expect(result).toContain('Col A');
expect(result).toContain('Col B');
expect(result).toContain('1');
expect(result).toContain('4');
});
it('renders table headers in bold', () => {
const result = renderMarkdown('| Name | Value |\n|------|-------|\n| a | b |');
// Header cells should have bold ANSI
const lines = result.split('\n');
const headerLine = lines[0];
expect(headerLine).toContain('\x1b[1m');
});
it('renders horizontal rules', () => {
const result = renderMarkdown('---');
expect(result).toContain('─');
});
it('renders links with underline and blue URL', () => {
const result = renderMarkdown('[click here](https://example.com)');
expect(result).toContain('\x1b[4m');
expect(result).toContain('click here');
expect(result).toContain('\x1b[34m');
expect(result).toContain('https://example.com');
});
});
+64 -11
View File
@@ -38,6 +38,8 @@ function convertHtmlToTerminal(text: string): string {
}
// Custom renderer as an object for marked.use()
// Methods that contain inline children use this.parser.parseInline(tokens)
// Methods that contain block children use this.parser.parse(tokens)
const terminalRenderer: RendererObject = {
code({ text, lang }: Tokens.Code): string {
try {
@@ -59,34 +61,85 @@ const terminalRenderer: RendererObject = {
return `\x1b[3m${text}\x1b[0m`; // Italic
},
paragraph({ text }: Tokens.Paragraph): string {
return text + '\n';
paragraph({ tokens }: Tokens.Paragraph): string {
return this.parser.parseInline(tokens) + '\n';
},
list({ items, ordered }: Tokens.List): string {
list({ items, ordered, start }: Tokens.List): string {
let body = '';
for (let i = 0; i < items.length; i++) {
const item = items[i];
const bullet = ordered ? `${i + 1}. ` : ' • ';
body += bullet + item.text + '\n';
const bullet = ordered ? `${(start || 1) + i}. ` : ' • ';
const content = this.parser.parse(item.tokens).replace(/\n$/, '');
body += bullet + content + '\n';
}
return body;
},
listitem({ text }: Tokens.ListItem): string {
return text;
listitem({ tokens }: Tokens.ListItem): string {
return this.parser.parse(tokens);
},
heading({ text }: Tokens.Heading): string {
return `\x1b[1m\x1b[4m${text}\x1b[0m\n`; // Bold + underline
heading({ tokens, depth }: Tokens.Heading): string {
const text = this.parser.parseInline(tokens);
const prefix = depth <= 2 ? '' : '#'.repeat(depth) + ' ';
return `\x1b[1m\x1b[4m${prefix}${text}\x1b[0m\n`; // Bold + underline
},
link({ text, href }: Tokens.Link): string {
return `\x1b[4m${text}\x1b[0m (\x1b[34m${href}\x1b[0m)`; // Underline text, blue URL
},
blockquote({ text }: Tokens.Blockquote): string {
return text.split('\n').map((line: string) => `${line}`).join('\n') + '\n';
blockquote({ tokens }: Tokens.Blockquote): string {
const body = this.parser.parse(tokens);
return body.split('\n').map((line: string) => `${line}`).join('\n') + '\n';
},
table({ header, rows, align }: Tokens.Table): string {
// Render cell contents
const headerTexts = header.map((cell: Tokens.TableCell) =>
this.parser.parseInline(cell.tokens)
);
const rowTexts = rows.map((row: Tokens.TableCell[]) =>
row.map((cell: Tokens.TableCell) => this.parser.parseInline(cell.tokens))
);
// Calculate column widths (strip ANSI for measurement)
const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, '');
const colWidths = headerTexts.map((h: string, i: number) => {
const cellWidths = rowTexts.map((row: string[]) => stripAnsi(row[i] || '').length);
return Math.max(stripAnsi(h).length, ...cellWidths);
});
// Pad cell to width respecting ANSI codes
const pad = (text: string, width: number, alignment: string | null) => {
const visible = stripAnsi(text).length;
const diff = width - visible;
if (diff <= 0) return text;
if (alignment === 'right') return ' '.repeat(diff) + text;
if (alignment === 'center') {
const left = Math.floor(diff / 2);
return ' '.repeat(left) + text + ' '.repeat(diff - left);
}
return text + ' '.repeat(diff);
};
// Build header row
const headerRow = ' ' + headerTexts.map((h: string, i: number) =>
`\x1b[1m${pad(h, colWidths[i], align[i])}\x1b[0m`
).join(' │ ');
// Build separator
const separator = '─' + colWidths.map((w: number) => '─'.repeat(w)).join('─┼─') + '─';
// Build data rows
const dataRows = rowTexts.map((row: string[]) =>
' ' + row.map((cell: string, i: number) =>
pad(cell, colWidths[i], align[i])
).join(' │ ')
);
return [headerRow, separator, ...dataRows].join('\n') + '\n';
},
hr(): string {