From 4cc29f534ad5625a414233c116a38f329fded8e2 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Tue, 10 Feb 2026 11:29:57 -0800 Subject: [PATCH] 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 --- package.json | 2 - pnpm-lock.yaml | 113 ----------------------------- src/frontends/tui/markdown.test.ts | 91 +++++++++++++++++++++-- src/frontends/tui/markdown.ts | 75 ++++++++++++++++--- 4 files changed, 150 insertions(+), 131 deletions(-) diff --git a/package.json b/package.json index e4610d3..0b39c20 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,6 @@ "license": "MIT", "devDependencies": { "@types/better-sqlite3": "^7.6.0", - "@types/marked-terminal": "^6.1.1", "@types/node": "^22.0.0", "@types/react": "^19.0.0", "@types/turndown": "^5.0.0", @@ -56,7 +55,6 @@ "ink-text-input": "^6.0.0", "linkedom": "^0.18.0", "marked": "^17.0.1", - "marked-terminal": "^7.3.0", "ollama": "^0.5.0", "openai": "^4.0.0", "puppeteer-core": "^24.37.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 69e812b..fa57b35 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,9 +59,6 @@ importers: marked: specifier: ^17.0.1 version: 17.0.1 - marked-terminal: - specifier: ^7.3.0 - version: 7.3.0(marked@17.0.1) ollama: specifier: ^0.5.0 version: 0.5.18 @@ -93,9 +90,6 @@ importers: '@types/better-sqlite3': specifier: ^7.6.0 version: 7.6.13 - '@types/marked-terminal': - specifier: ^6.1.1 - version: 6.1.1 '@types/node': specifier: ^22.0.0 version: 22.19.7 @@ -275,10 +269,6 @@ packages: resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} - '@colors/colors@1.5.0': - resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} - engines: {node: '>=0.1.90'} - '@discordjs/builders@1.13.1': resolution: {integrity: sha512-cOU0UDHc3lp/5nKByDxkmRiNZBpdp0kx55aarbiAfakfKJHlxv/yFW1zmIqCAmwH5CRlrH9iMFKJMpvW4DPB+w==} engines: {node: '>=16.11.0'} @@ -708,10 +698,6 @@ packages: resolution: {integrity: sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==} engines: {node: '>=v14.0.0', npm: '>=7.0.0'} - '@sindresorhus/is@4.6.0': - resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} - engines: {node: '>=10'} - '@slack/bolt@4.6.0': resolution: {integrity: sha512-xPgfUs2+OXSugz54Ky07pA890+Qydk22SYToi8uGpXeHSt1JWwFJkRyd/9Vlg5I1AdfdpGXExDpwnbuN9Q/2dQ==} engines: {node: '>=18', npm: '>=8.6.0'} @@ -939,9 +925,6 @@ packages: '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} - '@types/cardinal@2.1.1': - resolution: {integrity: sha512-/xCVwg8lWvahHsV2wXZt4i64H1sdL+sN1Uoq7fAc8/FA6uYHjuIveDwPwvGUYp4VZiv85dVl6J/Bum3NDAOm8g==} - '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -969,9 +952,6 @@ packages: '@types/jsonwebtoken@9.0.10': resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} - '@types/marked-terminal@6.1.1': - resolution: {integrity: sha512-DfoUqkmFDCED7eBY9vFUhJ9fW8oZcMAK5EwRDQ9drjTbpQa+DnBTQQCwWhTFVf4WsZ6yYcJTI8D91wxTWXRZZQ==} - '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} @@ -1292,10 +1272,6 @@ packages: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} - char-regex@1.0.2: - resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} - engines: {node: '>=10'} - check-error@2.1.3: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} @@ -1321,10 +1297,6 @@ packages: engines: {node: '>=8.0.0', npm: '>=5.0.0'} hasBin: true - cli-table3@0.6.5: - resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} - engines: {node: 10.* || >= 12.*} - cli-truncate@5.1.1: resolution: {integrity: sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==} engines: {node: '>=20'} @@ -1514,9 +1486,6 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - emojilib@2.4.0: - resolution: {integrity: sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw==} - encodeurl@2.0.0: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} @@ -2158,17 +2127,6 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - marked-terminal@7.3.0: - resolution: {integrity: sha512-t4rBvPsHc57uE/2nJOLmMbZCQ4tgAccAED3ngXQqW6g+TxA488JzJ+FK3lQkzBQOI1mRV/r/Kq+1ZlJ4D0owQw==} - engines: {node: '>=16.0.0'} - peerDependencies: - marked: '>=1 <16' - - marked@11.2.0: - resolution: {integrity: sha512-HR0m3bvu0jAPYiIvLUUQtdg1g6D247//lvcekpHO1WMvbwDlwSkZAX9Lw4F4YHE1T0HaaNve0tuAWuV1UJ6vtw==} - engines: {node: '>= 18'} - hasBin: true - marked@17.0.1: resolution: {integrity: sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==} engines: {node: '>= 20'} @@ -2269,10 +2227,6 @@ packages: engines: {node: '>=10.5.0'} deprecated: Use your platform's native DOMException instead - node-emoji@2.2.0: - resolution: {integrity: sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==} - engines: {node: '>=18'} - node-fetch@2.7.0: resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} engines: {node: 4.x || >=6.0.0} @@ -2610,10 +2564,6 @@ packages: simple-get@4.0.1: resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} - skin-tone@2.0.0: - resolution: {integrity: sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==} - engines: {node: '>=8'} - slice-ansi@7.1.2: resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} engines: {node: '>=18'} @@ -2699,10 +2649,6 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} - supports-hyperlinks@3.2.0: - resolution: {integrity: sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==} - engines: {node: '>=14.18'} - tar-fs@2.1.4: resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} @@ -2812,10 +2758,6 @@ packages: resolution: {integrity: sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==} engines: {node: '>=18.17'} - unicode-emoji-modifier-base@1.0.0: - resolution: {integrity: sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==} - engines: {node: '>=4'} - universalify@2.0.1: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} @@ -3473,9 +3415,6 @@ snapshots: '@babel/helper-validator-identifier@7.28.5': {} - '@colors/colors@1.5.0': - optional: true - '@discordjs/builders@1.13.1': dependencies: '@discordjs/formatters': 0.6.2 @@ -3797,8 +3736,6 @@ snapshots: '@sapphire/snowflake@3.5.3': {} - '@sindresorhus/is@4.6.0': {} - '@slack/bolt@4.6.0(@types/express@5.0.6)': dependencies: '@slack/logger': 4.0.0 @@ -4179,8 +4116,6 @@ snapshots: '@types/connect': 3.4.38 '@types/node': 22.19.7 - '@types/cardinal@2.1.1': {} - '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -4216,13 +4151,6 @@ snapshots: '@types/ms': 2.1.0 '@types/node': 22.19.7 - '@types/marked-terminal@6.1.1': - dependencies: - '@types/cardinal': 2.1.1 - '@types/node': 22.19.7 - chalk: 5.6.2 - marked: 11.2.0 - '@types/ms@2.1.0': {} '@types/node-fetch@2.6.13': @@ -4584,8 +4512,6 @@ snapshots: chalk@5.6.2: {} - char-regex@1.0.2: {} - check-error@2.1.3: {} chownr@1.1.4: {} @@ -4611,12 +4537,6 @@ snapshots: parse5-htmlparser2-tree-adapter: 6.0.1 yargs: 16.2.0 - cli-table3@0.6.5: - dependencies: - string-width: 4.2.3 - optionalDependencies: - '@colors/colors': 1.5.0 - cli-truncate@5.1.1: dependencies: slice-ansi: 7.1.2 @@ -4808,8 +4728,6 @@ snapshots: emoji-regex@8.0.0: {} - emojilib@2.4.0: {} - encodeurl@2.0.0: {} end-of-stream@1.4.5: @@ -5551,19 +5469,6 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 - marked-terminal@7.3.0(marked@17.0.1): - dependencies: - ansi-escapes: 7.3.0 - ansi-regex: 6.2.2 - chalk: 5.6.2 - cli-highlight: 2.1.11 - cli-table3: 0.6.5 - marked: 17.0.1 - node-emoji: 2.2.0 - supports-hyperlinks: 3.2.0 - - marked@11.2.0: {} - marked@17.0.1: {} math-intrinsics@1.1.0: {} @@ -5634,13 +5539,6 @@ snapshots: node-domexception@1.0.0: {} - node-emoji@2.2.0: - dependencies: - '@sindresorhus/is': 4.6.0 - char-regex: 1.0.2 - emojilib: 2.4.0 - skin-tone: 2.0.0 - node-fetch@2.7.0: dependencies: whatwg-url: 5.0.0 @@ -6070,10 +5968,6 @@ snapshots: once: 1.4.0 simple-concat: 1.0.1 - skin-tone@2.0.0: - dependencies: - unicode-emoji-modifier-base: 1.0.0 - slice-ansi@7.1.2: dependencies: ansi-styles: 6.2.3 @@ -6166,11 +6060,6 @@ snapshots: dependencies: has-flag: 4.0.0 - supports-hyperlinks@3.2.0: - dependencies: - has-flag: 4.0.0 - supports-color: 7.2.0 - tar-fs@2.1.4: dependencies: chownr: 1.1.4 @@ -6288,8 +6177,6 @@ snapshots: undici@6.21.3: {} - unicode-emoji-modifier-base@1.0.0: {} - universalify@2.0.1: optional: true diff --git a/src/frontends/tui/markdown.test.ts b/src/frontends/tui/markdown.test.ts index ec1ca0e..bc1b9a6 100644 --- a/src/frontends/tui/markdown.test.ts +++ b/src/frontends/tui/markdown.test.ts @@ -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'); + }); }); diff --git a/src/frontends/tui/markdown.ts b/src/frontends/tui/markdown.ts index 6a00d9d..3c5c913 100644 --- a/src/frontends/tui/markdown.ts +++ b/src/frontends/tui/markdown.ts @@ -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 {