Rendering Markdown in the Terminal
This is part 3 of a series on building a terminal agent from scratch.
Previous parts: – Part 1: Building an Agent from Scratch – Part 2: The Conversation Loop
The complete source code is available on GitHub: https://github.com/dimiro1/agent-from-scratch/tree/main/03
In this part, we'll add markdown rendering to our terminal agent. LLMs return responses in markdown, so we need a way to display formatted text bold, italic, headers, code, and more, all using ANSI escape codes.
Let's get started.
How terminals render colors?
Terminals don't understand HTML or CSS. Instead, they use ANSI escape codes to apply colors and styles to text. These are special character sequences that tell the terminal “make the next text bold” or “color this red.”
The basic structure is simple:
\033[<code>m<text>\033[0m
Where:
– \033[ is the escape sequence (also written as \x1b[ or \e[)
– <code> is a number representing a style or color
– m marks the end of the escape sequence
– <text> is your actual content
– \033[0m resets back to default styling
Here are the most useful codes:
Text Colors:
– 31 = Red
– 32 = Green
– 33 = Yellow
– 34 = Blue
– 35 = Magenta
– 36 = Cyan
Styles:
– 0 = Reset all formatting
– 1 = Bold
– 2 = Dim
– 3 = Italic
– 4 = Underline
You can combine multiple codes by separating them with semicolons. For example, \033[1;31m produces bold red text.
Let's see it in action:
$ echo -e "\033[1;32mBold Green Text\033[0m"
Bold Green Text # (but in bold and green!)
Creating an ANSI namespace
Before we dive into markdown rendering, let's create a dedicated namespace for ANSI formatting. This will give us a clean, reusable way to apply colors and styles.
Create src/termagent/ansi.clj:
(ns termagent.ansi)
(def codes
{:reset "\033[0m"
:bold "\033[1m"
:dim "\033[2m"
:italic "\033[3m"
:underline "\033[4m"
:red "\033[31m"
:green "\033[32m"
:yellow "\033[33m"
:blue "\033[34m"
:magenta "\033[35m"
:cyan "\033[36m"
:white "\033[37m"})
(defn render
"Renders text with ANSI codes. Takes a vector [style text] or [style1 style2 ... text]
where styles are keywords and text is a string."
[v]
(if (vector? v)
(let [styles (butlast v)
text (last v)
style-codes (apply str (map codes styles))]
(str style-codes text (:reset codes)))
v))
The render function takes a vector where the last element is the text, and everything before it are style keywords. Let's see it in action:
(render [:red "Hello"])
;; => "\033[31mHello\033[0m"
(render [:bold :red "Bold Red"])
;; => "\033[1m\033[31mBold Red\033[0m"
(render [:underline :cyan "Underlined Cyan"])
;; => "\033[4m\033[36mUnderlined Cyan\033[0m"
If you pass a plain string (not a vector), it returns it unchanged. This makes it easy to conditionally apply styling.
Let's use this to improve our prompts. Update termagent.core:
(ns termagent.core
(:require [termagent.openai :as openai]
[termagent.ansi :as ansi]))
(defn read-user-input []
(print (ansi/render [:bold :cyan "User: "]))
(flush)
(read-line))
(defn -main []
(loop [history []]
(let [input (read-user-input)]
(case input
"exit" (println "Bye")
nil? (println "Bye")
(do
(let [baseurl "https://api.openai.com/v1"
api-key (System/getenv "OPENAI_API_KEY")
model "gpt-4o-mini"
history (conj history {:role "user" :content input})
response (openai/generate baseurl api-key model history)
ai-reply (openai/get-reply response)]
(println (ansi/render [:bold :green "Assistant:"]))
(println ai-reply)
(recur (conj history {:role "assistant" :content ai-reply}))))))))
Much better! Now our prompts have color.
$ clj -M:run
User: Hello
Assistant:
Hello! How can I assist you today?
The markdown renderer
Now let's create the markdown renderer. The approach is simple:
- Use regex patterns to match markdown syntax
- Replace matches with text wrapped in ANSI codes
- Always reset formatting after each element
We're using regex because of its simplicity. You could use a complete markdown parser that handles corner cases and more complex elements, but since the focus here is learning, regex keeps things straightforward.
Create src/termagent/markdown.clj:
(ns termagent.markdown
(:require [clojure.string :as str]
[termagent.ansi :as ansi]))
Now we can use the ansi/render function we created earlier. This keeps our markdown rendering functions clean and focused on pattern matching.
Implementing Basic Markdown Elements
Let's start with the simplest markdown elements: bold and italic text.
Bold Text
Bold text in markdown is surrounded by **:
(defn render-bold [text]
(str/replace text
#"\*\*(.+?)\*\*"
(fn [[_ content]]
(ansi/render [:bold content]))))
Italic Text
Italic text uses *:
(defn render-italic [text]
(str/replace text
#"\*(.+?)\*"
(fn [[_ content]]
(ansi/render [:italic content]))))
Strikethrough
Strikethrough text uses ~~:
(defn render-strikethrough [text]
(str/replace text
#"~~(.+?)~~"
(fn [[_ content]]
(ansi/render [:dim content]))))
Inline Code
Inline code uses backticks:
(defn render-inline-code [text]
(str/replace text
#"`(.+?)`"
(fn [[_ content]]
(ansi/render [:cyan content]))))
Headers
Headers in markdown start with one or more # symbols. We'll render them in bold with different colors:
(defn render-headers [text]
(-> text
(str/replace #"(?m)^###### (.+)$"
(fn [[_ content]]
(ansi/render [:bold :white content])))
(str/replace #"(?m)^##### (.+)$"
(fn [[_ content]]
(ansi/render [:bold :white content])))
(str/replace #"(?m)^#### (.+)$"
(fn [[_ content]]
(ansi/render [:bold :yellow content])))
(str/replace #"(?m)^### (.+)$"
(fn [[_ content]]
(ansi/render [:bold :yellow content])))
(str/replace #"(?m)^## (.+)$"
(fn [[_ content]]
(ansi/render [:bold :cyan content])))
(str/replace #"(?m)^# (.+)$"
(fn [[_ content]]
(ansi/render [:bold :magenta content])))))
Images
Markdown images use the format . We'll render them with a descriptive label:
(defn render-images [text]
(str/replace text
#"!\[(.+?)\]\((.+?)\)"
(fn [[_ alt-text url]]
(str "Image: " alt-text " → " (ansi/render [:dim url])))))
Links
Markdown links use the format [text](url). We'll render the text in blue and underlined, followed by the URL in dim:
(defn render-links [text]
(str/replace text
#"\[(.+?)\]\((.+?)\)"
(fn [[_ link-text url]]
(str (ansi/render [:blue :underline link-text]) " → " (ansi/render [:dim url])))))
Task Items
Task items are a special type of list item with checkboxes. Checked items use [x] or [X], and unchecked items use [ ]:
(defn render-tasks [text]
(-> text
(str/replace #"(?m)^(\s*)[-*+] \[x\] (.+)$"
(fn [[_ indent content]]
(str indent (ansi/render [:green "[✓]"]) " " content)))
(str/replace #"(?m)^(\s*)[-*+] \[X\] (.+)$"
(fn [[_ indent content]]
(str indent (ansi/render [:green "[✓]"]) " " content)))
(str/replace #"(?m)^(\s*)[-*+] \[ \] (.+)$"
(fn [[_ indent content]]
(str indent "[ ] " content)))))
Lists
Let's handle both unordered lists (starting with -, *, or +) and ordered lists (starting with numbers):
(defn render-lists [text]
(-> text
(str/replace #"(?m)^(\s*)[-*+] (.+)$"
(fn [[_ indent content]]
(str indent (ansi/render [:cyan "• "]) content)))
(str/replace #"(?m)^(\s*)(\d+)\. (.+)$"
(fn [[_ indent number content]]
(str indent (ansi/render [:cyan (str number ". ")]) content)))))
Blockquotes
Blockquotes start with >:
(defn render-blockquotes [text]
(str/replace text
#"(?m)^> (.+)$"
(fn [[_ content]]
(ansi/render [:dim (str "│ " content)]))))
Horizontal Rules
Horizontal rules are created with ---, ***, or ___:
(defn render-horizontal-rules [text]
(str/replace text
#"(?m)^(---|\*\*\*|___)$"
(fn [[_]]
(ansi/render [:dim "────────────────────────────────"]))))
Putting It All Together
Now we need a main function that applies all these transformations:
(defn render [text]
(-> text
render-horizontal-rules
render-headers
render-blockquotes
render-tasks
render-lists
render-images
render-links
render-bold
render-strikethrough
render-italic
render-inline-code))
Order matters! We process block elements first (headers, blockquotes), then tasks before lists (to avoid conflicts), then inline elements last.
Testing Our Renderer
Let's create a showcase function to demonstrate all our markdown features:
(defn showcase
"Demonstrates all markdown rendering features.
Run with: clj -M -e \"(require '[termagent.markdown :as md]) (md/showcase)\""
[]
(let [sample-text "# Markdown Elements Showcase
## Headers
# This is an H1
## This is an H2
### This is an H3
#### This is an H4
##### This is an H5
###### This is an H6
## Text Styling
- **Bold Text**
- *Italic Text*
- ~~Strikethrough~~
## Lists
### Unordered List
- Item 1
- Item 2
- Subitem 2.1
- Subitem 2.2
### Ordered List
1. First Item
2. Second Item
1. Subitem 2.1
2. Subitem 2.2
## Links
[OpenAI](https://openai.com)
## Images

## Tasks
- [x] Completed task
- [ ] Incomplete task
- [X] Another completed task
## Code
Here is some `inline code` example.
## Blockquotes
> This is a blockquote with important information.
## Horizontal Rule
---
End of showcase."]
(println (render sample-text))))
You can test it:
$ clj -M -e "(require '[termagent.markdown :as md]) (md/showcase)"

Integrating with Our Agent
Now let's integrate this into our conversation loop. Update termagent.core:
(ns termagent.core
(:require [termagent.openai :as openai]
[termagent.markdown :as md]
[termagent.ansi :as ansi]))
(defn read-user-input []
(print (ansi/render [:bold :cyan "User: "]))
(flush)
(read-line))
(defn -main []
(loop [history []]
(let [input (read-user-input)]
(case input
"exit" (println "Bye")
nil? (println "Bye")
(do
(let [baseurl "https://api.openai.com/v1"
api-key (System/getenv "OPENAI_API_KEY")
model "gpt-4o-mini"
history (conj history {:role "user" :content input})
response (openai/generate baseurl api-key model history)
ai-reply (openai/get-reply response)]
(println (ansi/render [:bold :green "Assistant:"]))
(println (md/render ai-reply))
(recur (conj history {:role "assistant" :content ai-reply}))))))))
The changes:
1. Require the markdown and ANSI namespaces
2. Use ANSI styling for prompts
3. Wrap the AI's reply with md/render
Test it:
$ clj -M:run
User: Explain what Clojure is
Assistant:
# Clojure
Clojure is a **modern, functional programming language** that runs on the `Java Virtual Machine` (JVM).
It was created by *Rich Hickey* in 2007.
## Key Features
- Immutable data structures
- First-class functions
- Lisp syntax
> Clojure encourages a functional programming style.
Learn more at [clojure.org](https://clojure.org).
What We've Built
You now have: – ANSI escape codes for terminal styling – A reusable ANSI namespace – A markdown renderer that supports: – Headers (H1-H6) – Text styling (bold, italic, strikethrough) – Links and images – Task items – Lists – Blockquotes – Inline code – Horizontal rules – Markdown rendering integrated into your agent – Colored prompts
What's Next
Our renderer handles most common markdown elements, but we're still missing one: code blocks with syntax highlighting. In the next part, we'll add syntax highlighting for different languages using regex patterns.
Hope to see you there!