Skip to content

Commit b06c44f

Browse files
committed
cli: Improve markdown table rendering with box-drawing characters
- Use Unicode box-drawing characters (┌┬┐│├┼┤└┴┘─) for table borders - Add truncateText and padText helpers using string-width for proper Unicode handling - Proportionally shrink columns when table exceeds available width - Add tests for truncation and proportional shrinking behavior
1 parent f47de99 commit b06c44f

File tree

2 files changed

+242
-33
lines changed

2 files changed

+242
-33
lines changed

cli/src/utils/__tests__/markdown-renderer.test.tsx

Lines changed: 92 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ describe('markdown renderer', () => {
182182
const output = renderMarkdown(markdown)
183183
const nodes = flattenNodes(output)
184184

185-
// Check that table structure is rendered (pipes and separators)
185+
// Check that table structure is rendered with box-drawing characters
186186
const textContent = nodes
187187
.map((node) => {
188188
if (typeof node === 'string') return node
@@ -199,8 +199,9 @@ describe('markdown renderer', () => {
199199
expect(textContent).toContain('Jane')
200200
expect(textContent).toContain('30')
201201
expect(textContent).toContain('25')
202-
expect(textContent).toContain('|')
203-
expect(textContent).toContain('---')
202+
// Table uses box-drawing characters for borders
203+
expect(textContent).toContain('│')
204+
expect(textContent).toContain('─')
204205
})
205206

206207
test('renders code fence followed by text with quotes correctly', () => {
@@ -321,4 +322,92 @@ codebuff "implement feature" --verbose
321322
expect(inlineContent).toContain('git commit -m "fix: bug"')
322323
expect(nodes[2]).toBe(' to commit.')
323324
})
325+
326+
test('truncates table columns when content exceeds available width', () => {
327+
// Table with very long content that should be truncated
328+
const markdown = `| ID | This is a very long column header that should be truncated |
329+
| -- | ---------------------------------------------------------- |
330+
| 1 | This cell has extremely long content that definitely exceeds the width |`
331+
332+
// Use a narrow codeBlockWidth to force truncation
333+
const output = renderMarkdown(markdown, { codeBlockWidth: 50 })
334+
const nodes = flattenNodes(output)
335+
336+
const textContent = nodes
337+
.map((node) => {
338+
if (typeof node === 'string') return node
339+
if (React.isValidElement(node)) {
340+
return flattenChildren(node.props.children).join('')
341+
}
342+
return ''
343+
})
344+
.join('')
345+
346+
// Should contain ellipsis indicating truncation of the long column
347+
expect(textContent).toContain('…')
348+
// The short column content should be present (ID and 1 are short enough)
349+
expect(textContent).toContain('ID')
350+
expect(textContent).toContain('1')
351+
// Box-drawing characters should still be present
352+
expect(textContent).toContain('│')
353+
expect(textContent).toContain('─')
354+
// The long header should be truncated (not fully present)
355+
expect(textContent).not.toContain('This is a very long column header that should be truncated')
356+
})
357+
358+
test('does not truncate table columns when content fits available width', () => {
359+
const markdown = `| Name | Age |
360+
| ---- | --- |
361+
| John | 30 |`
362+
363+
// Use a wide codeBlockWidth so no truncation is needed
364+
const output = renderMarkdown(markdown, { codeBlockWidth: 80 })
365+
const nodes = flattenNodes(output)
366+
367+
const textContent = nodes
368+
.map((node) => {
369+
if (typeof node === 'string') return node
370+
if (React.isValidElement(node)) {
371+
return flattenChildren(node.props.children).join('')
372+
}
373+
return ''
374+
})
375+
.join('')
376+
377+
// Should NOT contain ellipsis when content fits
378+
expect(textContent).not.toContain('…')
379+
// All content should be present in full
380+
expect(textContent).toContain('Name')
381+
expect(textContent).toContain('Age')
382+
expect(textContent).toContain('John')
383+
expect(textContent).toContain('30')
384+
})
385+
386+
test('proportionally shrinks table columns when table is too wide', () => {
387+
// Three columns of roughly equal width
388+
const markdown = `| Column One | Column Two | Column Three |
389+
| ---------- | ---------- | ------------ |
390+
| Value1 | Value2 | Value3 |`
391+
392+
// Very narrow width to force significant shrinking
393+
const output = renderMarkdown(markdown, { codeBlockWidth: 30 })
394+
const nodes = flattenNodes(output)
395+
396+
const textContent = nodes
397+
.map((node) => {
398+
if (typeof node === 'string') return node
399+
if (React.isValidElement(node)) {
400+
return flattenChildren(node.props.children).join('')
401+
}
402+
return ''
403+
})
404+
.join('')
405+
406+
// Table structure should still be present
407+
expect(textContent).toContain('│')
408+
expect(textContent).toContain('┌')
409+
expect(textContent).toContain('└')
410+
// With such narrow width, some content should be truncated
411+
expect(textContent).toContain('…')
412+
})
324413
})

cli/src/utils/markdown-renderer.tsx

Lines changed: 150 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import React from 'react'
33
import remarkBreaks from 'remark-breaks'
44
import remarkGfm from 'remark-gfm'
55
import remarkParse from 'remark-parse'
6+
import stringWidth from 'string-width'
67
import { unified } from 'unified'
78

89
import { logger } from './logger'
@@ -642,57 +643,176 @@ const renderLink = (link: Link, state: RenderState): ReactNode[] => {
642643
]
643644
}
644645

646+
/**
647+
* Truncates text to fit within a specified width, adding ellipsis if needed.
648+
* Uses stringWidth to properly measure Unicode and wide characters.
649+
*/
650+
const truncateText = (text: string, maxWidth: number): string => {
651+
if (maxWidth < 1) return ''
652+
const textWidth = stringWidth(text)
653+
if (textWidth <= maxWidth) {
654+
return text
655+
}
656+
657+
// Need to truncate - leave room for ellipsis
658+
if (maxWidth === 1) return '…'
659+
660+
let truncated = ''
661+
let width = 0
662+
for (const char of text) {
663+
const charWidth = stringWidth(char)
664+
if (width + charWidth + 1 > maxWidth) break // +1 for ellipsis
665+
truncated += char
666+
width += charWidth
667+
}
668+
return truncated + '…'
669+
}
670+
671+
/**
672+
* Pads text to reach exact width using spaces.
673+
*/
674+
const padText = (text: string, targetWidth: number): string => {
675+
const currentWidth = stringWidth(text)
676+
if (currentWidth >= targetWidth) return text
677+
return text + ' '.repeat(targetWidth - currentWidth)
678+
}
679+
645680
const renderTable = (table: Table, state: RenderState): ReactNode[] => {
646-
const { palette, nextKey } = state
681+
const { palette, nextKey, codeBlockWidth } = state
647682
const nodes: ReactNode[] = []
648683

649-
// Calculate column widths
650-
const columnWidths: number[] = []
651-
table.children.forEach((row) => {
652-
(row as TableRow).children.forEach((cell, colIdx) => {
653-
const cellText = nodeToPlainText(cell as TableCell)
654-
const width = cellText.length
655-
columnWidths[colIdx] = Math.max(columnWidths[colIdx] || 0, width)
656-
})
684+
// Extract all rows and their plain text content
685+
const rows = table.children.map((row) => {
686+
const cells = (row as TableRow).children as TableCell[]
687+
return cells.map((cell) => nodeToPlainText(cell).trim())
657688
})
658689

690+
if (rows.length === 0) return nodes
691+
692+
// Determine number of columns
693+
const numCols = Math.max(...rows.map((r) => r.length))
694+
if (numCols === 0) return nodes
695+
696+
// Calculate natural column widths (minimum 3 chars per column)
697+
const naturalWidths: number[] = Array(numCols).fill(3)
698+
for (const row of rows) {
699+
for (let i = 0; i < row.length; i++) {
700+
const cellWidth = stringWidth(row[i] || '')
701+
naturalWidths[i] = Math.max(naturalWidths[i], cellWidth)
702+
}
703+
}
704+
705+
// Calculate total width needed:
706+
// Each column has its content width
707+
// Separators: " │ " between columns (3 chars each), none at edges
708+
const separatorWidth = 3 // ' │ '
709+
const numSeparators = numCols - 1
710+
const totalNaturalWidth =
711+
naturalWidths.reduce((a, b) => a + b, 0) + numSeparators * separatorWidth
712+
713+
// Available width for the table (leave some margin)
714+
const availableWidth = Math.max(20, codeBlockWidth - 2)
715+
716+
// Calculate final column widths
717+
let columnWidths: number[]
718+
if (totalNaturalWidth <= availableWidth) {
719+
// Table fits - use natural widths
720+
columnWidths = naturalWidths
721+
} else {
722+
// Table too wide - proportionally shrink columns
723+
const availableForContent = availableWidth - numSeparators * separatorWidth
724+
const totalNaturalContent = naturalWidths.reduce((a, b) => a + b, 0)
725+
const scale = availableForContent / totalNaturalContent
726+
727+
columnWidths = naturalWidths.map((w) => {
728+
// Minimum 3 chars, scale the rest
729+
return Math.max(3, Math.floor(w * scale))
730+
})
731+
732+
// Distribute any remaining width to columns that were clamped
733+
let usedWidth = columnWidths.reduce((a, b) => a + b, 0)
734+
let remaining = availableForContent - usedWidth
735+
for (let i = 0; i < columnWidths.length && remaining > 0; i++) {
736+
if (columnWidths[i] < naturalWidths[i]) {
737+
const add = Math.min(remaining, naturalWidths[i] - columnWidths[i])
738+
columnWidths[i] += add
739+
remaining -= add
740+
}
741+
}
742+
}
743+
744+
// Helper to render a horizontal separator line
745+
const renderSeparator = (leftChar: string, midChar: string, rightChar: string): void => {
746+
let line = leftChar
747+
columnWidths.forEach((width, idx) => {
748+
line += '─'.repeat(width + 2) // +2 for padding spaces
749+
line += idx < columnWidths.length - 1 ? midChar : rightChar
750+
})
751+
nodes.push(
752+
<span key={nextKey()} fg={palette.dividerFg}>
753+
{line}
754+
</span>,
755+
)
756+
nodes.push('\n')
757+
}
758+
759+
// Render top border
760+
renderSeparator('┌', '┬', '┐')
761+
659762
// Render each row
660763
table.children.forEach((row, rowIdx) => {
661764
const isHeader = rowIdx === 0
662765
const cells = (row as TableRow).children as TableCell[]
663766

664-
// Render cells in the row
665-
cells.forEach((cell, cellIdx) => {
666-
const cellNodes = renderNodes(
667-
cell.children as MarkdownNode[],
668-
state,
669-
cell.type,
767+
// Render row content
768+
for (let cellIdx = 0; cellIdx < numCols; cellIdx++) {
769+
const cell = cells[cellIdx]
770+
const cellText = cell ? nodeToPlainText(cell).trim() : ''
771+
const colWidth = columnWidths[cellIdx]
772+
773+
// Truncate and pad the cell content
774+
const displayText = padText(truncateText(cellText, colWidth), colWidth)
775+
776+
// Left border for first cell
777+
if (cellIdx === 0) {
778+
nodes.push(
779+
<span key={nextKey()} fg={palette.dividerFg}>
780+
781+
</span>,
782+
)
783+
}
784+
785+
// Cell content with padding
786+
nodes.push(
787+
<span
788+
key={nextKey()}
789+
fg={isHeader ? palette.headingFg[3] : undefined}
790+
attributes={isHeader ? TextAttributes.BOLD : undefined}
791+
>
792+
{' '}
793+
{displayText}
794+
{' '}
795+
</span>,
670796
)
671-
const cellWidth = columnWidths[cellIdx] || 10
672-
const cellText = nodeToPlainText(cell)
673-
const padding = ' '.repeat(Math.max(0, cellWidth - cellText.length))
674797

798+
// Separator or right border
675799
nodes.push(
676-
<span key={nextKey()} fg={isHeader ? palette.headingFg[3] : undefined}>
677-
{cellIdx === 0 ? '| ' : ' | '}
678-
{wrapSegmentsInFragments(cellNodes, nextKey())}
679-
{padding}
800+
<span key={nextKey()} fg={palette.dividerFg}>
801+
680802
</span>,
681803
)
682-
})
683-
nodes.push(' |\n')
804+
}
805+
nodes.push('\n')
684806

685807
// Add separator line after header
686808
if (isHeader) {
687-
nodes.push('|')
688-
columnWidths.forEach((width, idx) => {
689-
nodes.push(idx === 0 ? ' ' : ' | ')
690-
nodes.push('-'.repeat(width))
691-
})
692-
nodes.push(' |\n')
809+
renderSeparator('├', '┼', '┤')
693810
}
694811
})
695812

813+
// Render bottom border
814+
renderSeparator('└', '┴', '┘')
815+
696816
nodes.push('\n')
697817
return nodes
698818
}

0 commit comments

Comments
 (0)