@@ -3,6 +3,7 @@ import React from 'react'
33import remarkBreaks from 'remark-breaks'
44import remarkGfm from 'remark-gfm'
55import remarkParse from 'remark-parse'
6+ import stringWidth from 'string-width'
67import { unified } from 'unified'
78
89import { 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+
645680const 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