Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 24 additions & 18 deletions cli/src/utils/__tests__/markdown-renderer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -323,13 +323,13 @@ codebuff "implement feature" --verbose
expect(nodes[2]).toBe(' to commit.')
})

test('truncates table columns when content exceeds available width', () => {
// Table with very long content that should be truncated
const markdown = `| ID | This is a very long column header that should be truncated |
| -- | ---------------------------------------------------------- |
test('wraps table columns when content exceeds available width', () => {
// Table with very long content that should be wrapped
const markdown = `| ID | This is a very long column header that should wrap |
| -- | -------------------------------------------------- |
| 1 | This cell has extremely long content that definitely exceeds the width |`

// Use a narrow codeBlockWidth to force truncation
// Use a narrow codeBlockWidth to force wrapping
const output = renderMarkdown(markdown, { codeBlockWidth: 50 })
const nodes = flattenNodes(output)

Expand All @@ -343,24 +343,28 @@ codebuff "implement feature" --verbose
})
.join('')

// Should contain ellipsis indicating truncation of the long column
expect(textContent).toContain('…')
// The short column content should be present (ID and 1 are short enough)
// Should NOT contain ellipsis - content wraps instead of truncating
expect(textContent).not.toContain('…')
// The short column content should be present
expect(textContent).toContain('ID')
expect(textContent).toContain('1')
// Box-drawing characters should still be present
expect(textContent).toContain('│')
expect(textContent).toContain('─')
// The long header should be truncated (not fully present)
expect(textContent).not.toContain('This is a very long column header that should be truncated')
// The full content should be present across wrapped lines
expect(textContent).toContain('long')
expect(textContent).toContain('header')
expect(textContent).toContain('wrap')
expect(textContent).toContain('extremely')
expect(textContent).toContain('exceeds')
})

test('does not truncate table columns when content fits available width', () => {
test('does not wrap table columns when content fits available width', () => {
const markdown = `| Name | Age |
| ---- | --- |
| John | 30 |`

// Use a wide codeBlockWidth so no truncation is needed
// Use a wide codeBlockWidth so no wrapping is needed
const output = renderMarkdown(markdown, { codeBlockWidth: 80 })
const nodes = flattenNodes(output)

Expand All @@ -374,22 +378,20 @@ codebuff "implement feature" --verbose
})
.join('')

// Should NOT contain ellipsis when content fits
expect(textContent).not.toContain('…')
// All content should be present in full
expect(textContent).toContain('Name')
expect(textContent).toContain('Age')
expect(textContent).toContain('John')
expect(textContent).toContain('30')
})

test('proportionally shrinks table columns when table is too wide', () => {
test('wraps and shows full content when table is too wide', () => {
// Three columns of roughly equal width
const markdown = `| Column One | Column Two | Column Three |
| ---------- | ---------- | ------------ |
| Value1 | Value2 | Value3 |`

// Very narrow width to force significant shrinking
// Very narrow width to force significant wrapping
const output = renderMarkdown(markdown, { codeBlockWidth: 30 })
const nodes = flattenNodes(output)

Expand All @@ -407,7 +409,11 @@ codebuff "implement feature" --verbose
expect(textContent).toContain('│')
expect(textContent).toContain('┌')
expect(textContent).toContain('└')
// With such narrow width, some content should be truncated
expect(textContent).toContain('…')
// Full content should still be visible (wrapped, not truncated)
expect(textContent).not.toContain('…')
// All values should be present
expect(textContent).toContain('Value1')
expect(textContent).toContain('Value2')
expect(textContent).toContain('Value3')
})
})
140 changes: 87 additions & 53 deletions cli/src/utils/markdown-renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -644,28 +644,55 @@ const renderLink = (link: Link, state: RenderState): ReactNode[] => {
}

/**
* Truncates text to fit within a specified width, adding ellipsis if needed.
* Wraps text to fit within a specified width, returning an array of lines.
* Uses stringWidth to properly measure Unicode and wide characters.
* Performs word-wrapping where possible, falling back to character-level
* breaking for words that exceed the column width.
*/
const truncateText = (text: string, maxWidth: number): string => {
if (maxWidth < 1) return ''
const wrapText = (text: string, maxWidth: number): string[] => {
if (maxWidth < 1) return ['']
if (!text) return ['']
const textWidth = stringWidth(text)
if (textWidth <= maxWidth) {
return text
}

// Need to truncate - leave room for ellipsis
if (maxWidth === 1) return '…'

let truncated = ''
let width = 0
for (const char of text) {
const charWidth = stringWidth(char)
if (width + charWidth + 1 > maxWidth) break // +1 for ellipsis
truncated += char
width += charWidth
if (textWidth <= maxWidth) return [text]

const lines: string[] = []
let currentLine = ''
let currentWidth = 0
const tokens = text.split(/(\s+)/)

for (const token of tokens) {
if (!token) continue
const tokenWidth = stringWidth(token)
const isWhitespace = /^\s+$/.test(token)

// Skip leading whitespace on new lines
if (isWhitespace && currentWidth === 0) continue

if (tokenWidth > maxWidth && !isWhitespace) {
// Break long words character by character
for (const char of token) {
const charWidth = stringWidth(char)
if (currentWidth + charWidth > maxWidth) {
if (currentLine) lines.push(currentLine)
currentLine = char
currentWidth = charWidth
} else {
currentLine += char
currentWidth += charWidth
}
}
} else if (currentWidth + tokenWidth > maxWidth) {
if (currentLine) lines.push(currentLine.trimEnd())
currentLine = isWhitespace ? '' : token
currentWidth = isWhitespace ? 0 : tokenWidth
} else {
currentLine += token
currentWidth += tokenWidth
}
}
return truncated + '…'

if (currentLine) lines.push(currentLine.trimEnd())
return lines.length > 0 ? lines : ['']
}

/**
Expand Down Expand Up @@ -756,53 +783,60 @@ const renderTable = (table: Table, state: RenderState): ReactNode[] => {
nodes.push('\n')
}

// Pre-wrap all cell contents so we know the height of each row
const wrappedRows: string[][][] = rows.map((row) =>
Array.from({ length: numCols }, (_, i) => {
const cellText = row[i] || ''
return wrapText(cellText, columnWidths[i])
}),
)

// Render top border
renderSeparator('┌', '┬', '┐')

// Render each row
table.children.forEach((row, rowIdx) => {
// Render each row with word-wrapped cells
wrappedRows.forEach((wrappedCells, rowIdx) => {
const isHeader = rowIdx === 0
const cells = (row as TableRow).children as TableCell[]
const rowHeight = Math.max(...wrappedCells.map((lines) => lines.length), 1)

// Render each visual line in the row
for (let lineIdx = 0; lineIdx < rowHeight; lineIdx++) {
for (let cellIdx = 0; cellIdx < numCols; cellIdx++) {
const colWidth = columnWidths[cellIdx]
const lineText = wrappedCells[cellIdx][lineIdx] || ''
const displayText = padText(lineText, colWidth)

// Left border for first cell
if (cellIdx === 0) {
nodes.push(
<span key={nextKey()} fg={palette.dividerFg}>
</span>,
)
}

// Cell content with padding
nodes.push(
<span
key={nextKey()}
fg={isHeader ? palette.headingFg[3] : undefined}
attributes={isHeader ? TextAttributes.BOLD : undefined}
>
{' '}
{displayText}
{' '}
</span>,
)

// Render row content
for (let cellIdx = 0; cellIdx < numCols; cellIdx++) {
const cell = cells[cellIdx]
const cellText = cell ? nodeToPlainText(cell).trim() : ''
const colWidth = columnWidths[cellIdx]

// Truncate and pad the cell content
const displayText = padText(truncateText(cellText, colWidth), colWidth)

// Left border for first cell
if (cellIdx === 0) {
// Separator or right border
nodes.push(
<span key={nextKey()} fg={palette.dividerFg}>
</span>,
)
}

// Cell content with padding
nodes.push(
<span
key={nextKey()}
fg={isHeader ? palette.headingFg[3] : undefined}
attributes={isHeader ? TextAttributes.BOLD : undefined}
>
{' '}
{displayText}
{' '}
</span>,
)

// Separator or right border
nodes.push(
<span key={nextKey()} fg={palette.dividerFg}>
</span>,
)
nodes.push('\n')
}
nodes.push('\n')

// Add separator line after header
if (isHeader) {
Expand Down