Skip to main content

Markdown rendering

End-to-end flow

  1. file.renderMarkdown (tool) reads the file, parses it, and returns metadata.
  2. renderMarkdownToTerminal converts markdown to coloured terminal output.
  3. ToolResultPresenter decides when to render the markdown preview.

Tool implementation

src/tools/FileTools.ts
const renderMarkdown = ({
filePath,
includeHtml,
}: Schema.Schema.Type<typeof RenderMarkdownParameters>) =>
Effect.gen(function* () {
const resolved = yield* pathValidation.ensureWithinCwd(filePath)
const relPath = pathValidation.relativePath(resolved)
const content = yield* fs.readFileString(resolved)

// Handle non-markdown files gracefully
if (!isMarkdownFile(resolved)) {
return createNonMarkdownResult(relPath, content)
}

// Parse markdown and extract structure
const parsed = yield* Effect.promise(() => parseMarkdown(content))

return {
filePath: relPath,
isMarkdown: true,
content: parsed.raw,
plainText: parsed.plainText,
html: includeHtml ? parsed.html : undefined,
metadata: {
headings: parsed.metadata.headings,
links: parsed.metadata.links,
codeBlocks: parsed.metadata.codeBlocks.map((block) => ({
language: block.lang ?? 'text',
lineCount: block.code.split('\n').length,
})),
wordCount: parsed.metadata.wordCount,
lineCount: parsed.metadata.lineCount,
structure: {
headingCount: parsed.metadata.headings.length,
linkCount: parsed.metadata.links.length,
codeBlockCount: parsed.metadata.codeBlocks.length,
},
},
}
})
  • parseMarkdown returns raw, plainText, html, and detailed metadata.
  • Non-markdown files still return plain content so the assistant can respond gracefully.

Terminal renderer

src/utils/markdown/renderer.ts
// Convert markdown to colored terminal output
export const renderMarkdownToTerminal = (content: string, maxWidth = 80): string => {
const tokens = marked.lexer(content)
return renderTokensToTerminal(tokens, maxWidth)
}

The renderTokensToTerminal function wraps paragraphs, colors headings, formats lists, and prints fenced code blocks with indentation:

src/utils/markdown/renderer.ts
const renderTokensToTerminal = (tokens: Token[], maxWidth: number, indent = ''): string => {
const lines: string[] = []

for (const token of tokens) {
switch (token.type) {
case 'heading':
lines.push(TerminalColors.heading(`${indent}${'#'.repeat(token.depth)} ${token.text}`))
lines.push('')
break
case 'paragraph': {
const wrapped = wrapText(token.text, maxWidth - indent.length)
lines.push(TerminalColors.text(addIndent(wrapped, indent)))
lines.push('')
break
}
case 'list':
lines.push(renderListToTerminal(token as Tokens.List, maxWidth, indent))
lines.push('')
break
case 'code':
lines.push(TerminalColors.codeBlock(`${indent}\`\`\`${token.lang || ''}`))
token.text
.split('\n')
.forEach((codeLine) => lines.push(TerminalColors.code(`${indent}${codeLine}`)))
lines.push(TerminalColors.codeBlock(`${indent}\`\`\``))
lines.push('')
break
// ...other token cases...
}
}

return lines.join('\n').replace(/\n{3,}/g, '\n\n')
}
  • wrapText ensures paragraphs fit within maxWidth.
  • List and blockquote helpers add bullet/quote prefixes with consistent colouring.
  • Additional token handling (links, emphasis, horizontal rules) lives in the same function.

Display trigger

src/chat/ui/ToolResultPresenter.ts
if (
toolName === 'readFile' &&
result?.content &&
result.filePath?.toLowerCase().endsWith('.md') &&
result.content.length < 2000
) {
const rendered = renderMarkdownToTerminal(result.content, 76)
console.log(UI.indent(rendered, 2))
}
  • Large files are skipped to avoid flooding the terminal.
  • renderMarkdown metadata is still available even when the preview is suppressed.

Customisation ideas

  • Adjust maxWidth to match your terminal size.
  • Swap chalk colours in TerminalColors to match light/dark themes.
  • Extend the tool to include HTML output (includeHtml: true) for downstream processors.

Source

  • src/tools/FileTools.ts
  • src/utils/markdown/index.ts
  • src/utils/markdown/renderer.ts
  • src/chat/ui/ToolResultPresenter.ts