Skip to content

Commit 946f6e0

Browse files
committed
feat(markdown): improve table rendering with markdown support
- Add visible length calculation for markdown-formatted text - Implement proper truncation and wrapping of markdown content - Support inline markdown parsing in table cells - Export previously internal TextSegment interface and parse function
1 parent 713ee25 commit 946f6e0

3 files changed

Lines changed: 173 additions & 62 deletions

File tree

src/cli/tui/routes/workflow/components/shared/log-line.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ export interface LogLineProps {
8080
/**
8181
* Styled text segment from inline markdown parsing
8282
*/
83-
interface TextSegment {
83+
export interface TextSegment {
8484
text: string
8585
bold?: boolean
8686
code?: boolean
@@ -90,7 +90,7 @@ interface TextSegment {
9090
* Parse inline markdown and return styled segments
9191
* Handles **bold** and `code` patterns
9292
*/
93-
function parseInlineMarkdown(text: string): TextSegment[] {
93+
export function parseInlineMarkdown(text: string): TextSegment[] {
9494
const segments: TextSegment[] = []
9595
const regex = /(\*\*([^*]+)\*\*|`([^`]+)`)/g
9696
let lastIndex = 0

src/cli/tui/routes/workflow/components/shared/log-table.tsx

Lines changed: 95 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { useTerminalDimensions } from "@opentui/solid"
1212
import { useTheme } from "@tui/shared/context/theme"
1313
import { useUIState } from "../../context/ui-state"
1414
import { parseMarkdownTable, renderBoxTable } from "./markdown-table"
15+
import { parseInlineMarkdown } from "./log-line"
1516

1617
export interface LogTableProps {
1718
/** Raw markdown table lines */
@@ -20,6 +21,32 @@ export interface LogTableProps {
2021
maxWidth?: number
2122
}
2223

24+
// Box-drawing characters for detecting borders
25+
const BORDER_CHARS = new Set(['┌', '┐', '└', '┘', '┬', '┴', '├', '┤', '┼', '─', '│'])
26+
27+
/**
28+
* Parse a line into border and content segments for styling
29+
*/
30+
function parseLineSegments(line: string): { text: string; isBorder: boolean }[] {
31+
const segments: { text: string; isBorder: boolean }[] = []
32+
let current = ''
33+
let isBorder = false
34+
35+
for (const char of line) {
36+
const charIsBorder = BORDER_CHARS.has(char)
37+
if (charIsBorder !== isBorder && current) {
38+
segments.push({ text: current, isBorder })
39+
current = ''
40+
}
41+
isBorder = charIsBorder
42+
current += char
43+
}
44+
if (current) {
45+
segments.push({ text: current, isBorder })
46+
}
47+
return segments
48+
}
49+
2350
/**
2451
* Renders a markdown table with box-drawing characters
2552
*/
@@ -50,16 +77,46 @@ export function LogTable(props: LogTableProps) {
5077
<box flexDirection="column">
5178
<For each={renderedLines()}>
5279
{(line, index) => {
80+
const segments = parseLineSegments(line)
5381
// First line (header row after top border) gets bold
5482
const isHeaderRow = () => index() === 1 && renderedLines().length > 3
5583

5684
return (
57-
<text
58-
fg={themeCtx.theme.text}
59-
attributes={isHeaderRow() ? TextAttributes.BOLD : TextAttributes.NONE}
60-
>
61-
{line}
62-
</text>
85+
<box flexDirection="row">
86+
<For each={segments}>
87+
{(segment) => {
88+
// For border segments, render as-is
89+
if (segment.isBorder) {
90+
return (
91+
<text
92+
fg={themeCtx.theme.textMuted}
93+
attributes={TextAttributes.NONE}
94+
>
95+
{segment.text}
96+
</text>
97+
)
98+
}
99+
// For content segments, parse inline markdown
100+
const inlineSegments = parseInlineMarkdown(segment.text)
101+
return (
102+
<For each={inlineSegments}>
103+
{(inline) => (
104+
<text
105+
fg={inline.code ? themeCtx.theme.purple : themeCtx.theme.text}
106+
attributes={
107+
(isHeaderRow() || inline.bold)
108+
? TextAttributes.BOLD
109+
: TextAttributes.NONE
110+
}
111+
>
112+
{inline.text}
113+
</text>
114+
)}
115+
</For>
116+
)
117+
}}
118+
</For>
119+
</box>
63120
)
64121
}}
65122
</For>
@@ -93,52 +150,47 @@ export function LogTableStyled(props: LogTableProps & { borderColor?: number })
93150
return renderBoxTable(parsed, maxWidth())
94151
})
95152

96-
// Detect which parts of a line are borders vs content
97-
const parseLine = (line: string) => {
98-
const segments: { text: string; isBorder: boolean }[] = []
99-
let current = ''
100-
let isBorder = false
101-
const borderChars = new Set(['┌', '┐', '└', '┘', '┬', '┴', '├', '┤', '┼', '─', '│'])
102-
103-
for (const char of line) {
104-
const charIsBorder = borderChars.has(char)
105-
if (charIsBorder !== isBorder && current) {
106-
segments.push({ text: current, isBorder })
107-
current = ''
108-
}
109-
isBorder = charIsBorder
110-
current += char
111-
}
112-
if (current) {
113-
segments.push({ text: current, isBorder })
114-
}
115-
return segments
116-
}
117-
118153
return (
119154
<box flexDirection="column">
120155
<For each={renderedLines()}>
121156
{(line, index) => {
122-
const segments = parseLine(line)
157+
const segments = parseLineSegments(line)
123158
const isHeaderRow = () => index() === 1 && renderedLines().length > 3
124159

125160
return (
126161
<box flexDirection="row">
127162
<For each={segments}>
128-
{(segment) => (
129-
<text
130-
fg={segment.isBorder
131-
? (props.borderColor ?? themeCtx.theme.textMuted)
132-
: themeCtx.theme.text
133-
}
134-
attributes={isHeaderRow() && !segment.isBorder
135-
? TextAttributes.BOLD
136-
: TextAttributes.NONE
137-
}
138-
>
139-
{segment.text}
140-
</text>
141-
)}
163+
{(segment) => {
164+
// For border segments, render as-is
165+
if (segment.isBorder) {
166+
return (
167+
<text
168+
fg={props.borderColor ?? themeCtx.theme.textMuted}
169+
attributes={TextAttributes.NONE}
170+
>
171+
{segment.text}
172+
</text>
173+
)
174+
}
175+
// For content segments, parse inline markdown
176+
const inlineSegments = parseInlineMarkdown(segment.text)
177+
return (
178+
<For each={inlineSegments}>
179+
{(inline) => (
180+
<text
181+
fg={inline.code ? themeCtx.theme.purple : themeCtx.theme.text}
182+
attributes={
183+
(isHeaderRow() || inline.bold)
184+
? TextAttributes.BOLD
185+
: TextAttributes.NONE
186+
}
187+
>
188+
{inline.text}
189+
</text>
190+
)}
191+
</For>
192+
)
193+
}}
142194
</For>
143195
</box>
144196
)

src/cli/tui/routes/workflow/components/shared/markdown-table.ts

Lines changed: 76 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -141,22 +141,60 @@ export function parseMarkdownTable(lines: string[]): ParsedTable | null {
141141
}
142142
}
143143

144+
/**
145+
* Calculate the visible length of text after stripping inline markdown
146+
* Removes **bold** and `code` markers from length calculation
147+
*/
148+
function visibleLength(text: string): number {
149+
// Remove **bold** markers (but keep the content)
150+
let stripped = text.replace(/\*\*([^*]+)\*\*/g, '$1')
151+
// Remove `code` markers (but keep the content)
152+
stripped = stripped.replace(/`([^`]+)`/g, '$1')
153+
return stripped.length
154+
}
155+
156+
/**
157+
* Strip inline markdown markers from text
158+
*/
159+
function stripInlineMarkdown(text: string): string {
160+
let stripped = text.replace(/\*\*([^*]+)\*\*/g, '$1')
161+
stripped = stripped.replace(/`([^`]+)`/g, '$1')
162+
return stripped
163+
}
164+
165+
/**
166+
* Truncate text to a visible width, stripping markdown if needed
167+
* Returns plain text truncated to the specified visible width
168+
*/
169+
function truncateToVisibleWidth(text: string, width: number): string {
170+
// First strip markdown to get clean text for truncation
171+
const stripped = stripInlineMarkdown(text)
172+
if (stripped.length <= width) {
173+
return stripped
174+
}
175+
// Truncate and add ellipsis indicator if there's room
176+
if (width > 3) {
177+
return stripped.slice(0, width - 1) + '…'
178+
}
179+
return stripped.slice(0, width)
180+
}
181+
144182
/**
145183
* Calculate the width needed for each column
146184
*/
147185
function calculateColumnWidths(headers: string[], rows: string[][], columnCount: number): number[] {
148186
const widths: number[] = new Array(columnCount).fill(0)
149187

150-
// Check header widths
188+
// Check header widths (use visible length)
151189
headers.forEach((h, i) => {
152-
widths[i] = Math.max(widths[i], h.length)
190+
widths[i] = Math.max(widths[i], visibleLength(h))
153191
})
154192

155-
// Check row widths
193+
// Check row widths (use visible length)
156194
rows.forEach(row => {
157195
row.forEach((cell, i) => {
158196
if (i < columnCount) {
159-
widths[i] = Math.max(widths[i], cell.length)
197+
widths[i] = Math.max(widths[i], visibleLength(cell))
160198
}
161199
})
162200
})
@@ -167,10 +205,16 @@ function calculateColumnWidths(headers: string[], rows: string[][], columnCount:
167205

168206
/**
169207
* Pad a string according to alignment
208+
* Note: text may contain markdown markers, so we use visible length for padding
170209
*/
171210
function padCell(text: string, width: number, align: ColumnAlignment): string {
172-
const padding = width - text.length
173-
if (padding <= 0) return text.slice(0, width)
211+
const textVisibleLen = visibleLength(text)
212+
const padding = width - textVisibleLen
213+
214+
// If text is too long, truncate it properly (handles markdown)
215+
if (padding < 0) {
216+
return truncateToVisibleWidth(text, width)
217+
}
174218

175219
switch (align) {
176220
case 'center': {
@@ -186,36 +230,51 @@ function padCell(text: string, width: number, align: ColumnAlignment): string {
186230
}
187231
}
188232

233+
/**
234+
* Break a long word into chunks that fit within width
235+
* Strips markdown to avoid breaking mid-marker
236+
*/
237+
function breakLongWord(word: string, width: number): string[] {
238+
const stripped = stripInlineMarkdown(word)
239+
const chunks: string[] = []
240+
for (let i = 0; i < stripped.length; i += width) {
241+
chunks.push(stripped.slice(i, i + width))
242+
}
243+
return chunks.length > 0 ? chunks : [stripped]
244+
}
245+
189246
/**
190247
* Wrap text to fit within a given width
248+
* Uses visible length (excluding markdown markers) for width calculations
191249
*/
192250
function wrapText(text: string, width: number): string[] {
193251
if (width <= 0) return [text]
194-
if (text.length <= width) return [text]
252+
if (visibleLength(text) <= width) return [text]
195253

196254
const lines: string[] = []
197255
const words = text.split(' ')
198256
let currentLine = ''
199257

200258
for (const word of words) {
259+
const wordVisibleLen = visibleLength(word)
260+
const currentLineVisibleLen = visibleLength(currentLine)
261+
201262
if (currentLine.length === 0) {
202-
// First word - if too long, break it
203-
if (word.length > width) {
204-
for (let i = 0; i < word.length; i += width) {
205-
lines.push(word.slice(i, i + width))
206-
}
263+
// First word - if too long, break it into chunks
264+
if (wordVisibleLen > width) {
265+
const chunks = breakLongWord(word, width)
266+
lines.push(...chunks)
207267
} else {
208268
currentLine = word
209269
}
210-
} else if (currentLine.length + 1 + word.length <= width) {
270+
} else if (currentLineVisibleLen + 1 + wordVisibleLen <= width) {
211271
currentLine += ' ' + word
212272
} else {
213273
lines.push(currentLine)
214274
// Handle word longer than width
215-
if (word.length > width) {
216-
for (let i = 0; i < word.length; i += width) {
217-
lines.push(word.slice(i, i + width))
218-
}
275+
if (wordVisibleLen > width) {
276+
const chunks = breakLongWord(word, width)
277+
lines.push(...chunks)
219278
currentLine = ''
220279
} else {
221280
currentLine = word

0 commit comments

Comments
 (0)