Redraws assistant messages as styled markdown.

Claude Code mod that redraws assistant messages as colored markdown.
<table width="100%"> <tr> <th width="50%" align="center">Before</th> <th width="50%" align="center">After</th> </tr> <tr> <td width="50%" valign="top"><img width="100%" src="resources/claude.png" alt="Claude Code's default markdown rendering of examples/showcase.md"></td> <td width="50%" valign="top"><img width="100%" src="resources/claude-markdown-mod.png" alt="The same message rendered by md-render"></td> </tr> </table>
Both screenshots show the reply to "Print examples/showcase.md verbatim".
| Markdown | Rendering |
|---|---|
# Heading | bold purple |
bold | bold orange |
*italic* | italic amber |
*bold italic* | bold italic |
| strikethrough |
` code ` | green |
- item, 1. item | orange markers, nested lists indented |
> quote | dim │ bar, italic text |
| fenced code, tables | drawn by Claude Code's own renderer (syntax highlighting, wrapping) |
text | the text followed by the dim address |
| images, anything else | plain text |
Unclosed **, ` `, ~~`, or a fence (mid-stream) stay plain text until they close. If the message text can't be read or parsing throws, the default rendering is used.
The reply streams as plain text drawn by Claude Code and is restyled when the message completes. A mod can't change the streamed text: classic.MessageDisplay is bypassed for user mods by cc-plugin-sec-default.
From a marketplace (this repo is one):
claude plugin marketplace add deepskyblue86/claude-markdown-mod
claude plugin install md-render@md-render-marketplace
Try it for one session without installing:
claude --plugin-dir /path/to/claude-markdown-mod
Colors and the style mapping are in CONFIG at the top of hooks/register.tsx. A style's color is a key of CONFIG.colors or a raw color.
No build step: Claude Code loads hooks/register.tsx directly.
claude plugin validate .
claude plugin test .
.claude-plugin/types/ is generated by Claude Code on load and is gitignored.
MIT. See LICENSE.
hooks/register.tsx 341 lines1import type { Register } from 'claude-code'
2
3type Style = { color?: string; bold?: boolean; italic?: boolean; dimColor?: boolean; strikethrough?: boolean }
4
5// Style colors name a key of `colors`, or are a raw color.
6export const CONFIG = {
7 colors: {
8 purple: '#9d7cd8',
9 orange: '#f5a742',
10 amber: '#e5c07b',
11 green: '#7fd88f',
12 } as Record<string, string>,
13 styles: {
14 heading: { bold: true, color: 'purple' },
15 bold: { bold: true, color: 'orange' },
16 italic: { italic: true, color: 'amber' },
17 code: { color: 'green' },
18 strike: { strikethrough: true },
19 linkUrl: { dimColor: true },
20 listMarker: { color: 'orange' },
21 quoteBar: { dimColor: true },
22 quote: { italic: true },
23 } as Record<string, Style>,
24 glyphs: { bullet: '-', quoteBar: '│', reply: '●' },
25 indent: 2,
26 debugTiming: false,
27}
28
29type Mark = 'bold' | 'italic' | 'code' | 'strike' | 'linkUrl'
30type Run = { text: string; marks: Mark[] }
31type Item = { marker: string; text: string; children: Item[] }
32type Block =
33 | { kind: 'heading' | 'para' | 'plain'; text: string }
34 | { kind: 'native'; raw: string }
35 | { kind: 'list'; items: Item[] }
36 | { kind: 'quote'; lines: string[] }
37
38const LINK = /^!?\[[^\]\n]*\]\([^)\n]*\)/
39const MD_LINK = /^\[([^\]\n]+)\]\(([^)\s]+)\)/
40const FENCE = /^\s*(`{3,})\s*([^\s`]*)/
41const HEADING = /^#{1,6}\s+(.*)$/
42const QUOTE = /^\s*>\s?(.*)$/
43const ITEM = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/
44const TABLE = /^\s*\|/
45
46// An unclosed marker turns the rest of the text into plain text.
47function parseInline(s: string, marks: Mark[] = []): Run[] {
48 const out: Run[] = []
49 let buf = ''
50 const flush = () => {
51 if (buf) out.push({ text: buf, marks })
52 buf = ''
53 }
54
55 let i = 0
56 while (i < s.length) {
57 const ch = s[i]
58 const link = ch === '[' ? MD_LINK.exec(s.slice(i)) : null
59
60 if (ch === '\\' && i + 1 < s.length && '\\`*'.includes(s[i + 1])) {
61 buf += s[i + 1]
62 i += 2
63 } else if (ch === '`') {
64 const end = s.indexOf('`', i + 1)
65 if (end < 0) {
66 buf += s.slice(i)
67 break
68 }
69 if (end === i + 1) {
70 buf += '``'
71 } else {
72 flush()
73 out.push({ text: s.slice(i + 1, end), marks: [...marks, 'code'] })
74 }
75 i = end + 1
76 } else if (s.startsWith('***', i) || s.startsWith('~~', i)) {
77 const strike = s.startsWith('~~', i)
78 const open = strike ? '~~' : '***'
79 const end = s.indexOf(open, i + open.length)
80 if (end < 0) {
81 buf += s.slice(i)
82 break
83 }
84 if (end === i + open.length) {
85 buf += open + open
86 } else {
87 flush()
88 const inner = strike ? ['strike' as const] : ['bold' as const, 'italic' as const]
89 out.push(...parseInline(s.slice(i + open.length, end), [...marks, ...inner]))
90 }
91 i = end + open.length
92 } else if (s.startsWith('**', i)) {
93 const end = s.indexOf('**', i + 2)
94 if (end < 0) {
95 buf += s.slice(i)
96 break
97 }
98 if (end === i + 2) {
99 buf += '****'
100 } else {
101 flush()
102 out.push(...parseInline(s.slice(i + 2, end), [...marks, 'bold']))
103 }
104 i = end + 2
105 } else if (ch === '*' && i + 1 < s.length && !/\s/.test(s[i + 1])) {
106 let end = i + 1
107 while (end < s.length && !(s[end] === '*' && s[end + 1] !== '*')) {
108 end += s.startsWith('**', end) || s[end] === '\\' ? 2 : 1
109 }
110 if (end >= s.length) {
111 buf += s.slice(i)
112 break
113 }
114 flush()
115 out.push(...parseInline(s.slice(i + 1, end), [...marks, 'italic']))
116 i = end + 1
117 } else if (link) {
118 flush()
119 out.push(...parseInline(link[1], marks))
120 if (link[1] !== link[2]) out.push({ text: ` (${link[2]})`, marks: [...marks, 'linkUrl'] })
121 i += link[0].length
122 } else if (ch === '[' || ch === '!') {
123 const image = LINK.exec(s.slice(i))
124 buf += image ? image[0] : ch
125 i += image ? image[0].length : 1
126 } else {
127 buf += ch
128 i += 1
129 }
130 }
131
132 flush()
133 return out
134}
135
136const NATIVE_LIMIT = 10000
137
138// A header row plus a delimiter row of the same width; mid-stream it stays plain text until then.
139function isCompleteTable(lines: string[]): boolean {
140 const cells = (l: string) => l.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(c => c.trim())
141 if (lines.length < 2) return false
142 const delim = cells(lines[1])
143 return delim.length === cells(lines[0]).length && delim.every(c => /^:?-+:?$/.test(c))
144}
145
146function nest(flat: { indent: number; item: Item }[]): Item[] {
147 const root: Item[] = []
148 const stack = [{ indent: -1, children: root }]
149 for (const { indent, item } of flat) {
150 while (stack.length > 1 && indent <= stack[stack.length - 1].indent) stack.pop()
151 stack[stack.length - 1].children.push(item)
152 stack.push({ indent, children: item.children })
153 }
154 return root
155}
156
157const startsBlock = (l: string) =>
158 FENCE.test(l) || HEADING.test(l) || QUOTE.test(l) || ITEM.test(l) || TABLE.test(l)
159
160function parseBlocks(src: string): Block[] {
161 const lines = src.replace(/\r\n?/g, '\n').split('\n')
162 const blocks: Block[] = []
163 let i = 0
164
165 while (i < lines.length) {
166 const line = lines[i]
167 if (!line.trim()) {
168 i++
169 continue
170 }
171
172 const fence = FENCE.exec(line)
173 if (fence) {
174 let j = i + 1
175 const isClose = (l: string) => /^`+$/.test(l.trim()) && l.trim().length >= fence[1].length
176 while (j < lines.length && !isClose(lines[j])) j++
177 if (j < lines.length) {
178 blocks.push({ kind: 'native', raw: lines.slice(i, j + 1).join('\n') })
179 i = j + 1
180 } else {
181 blocks.push({ kind: 'plain', text: lines.slice(i).join('\n') })
182 i = lines.length
183 }
184 continue
185 }
186
187 const heading = HEADING.exec(line)
188 if (heading) {
189 blocks.push({ kind: 'heading', text: heading[1] })
190 i++
191 continue
192 }
193
194 if (QUOTE.test(line)) {
195 const quoted: string[] = []
196 for (let m; i < lines.length && (m = QUOTE.exec(lines[i])); i++) quoted.push(m[1])
197 blocks.push({ kind: 'quote', lines: quoted })
198 continue
199 }
200
201 if (ITEM.test(line)) {
202 const flat: { indent: number; item: Item }[] = []
203 const first = ITEM.exec(line)!
204 const isOrdered = (marker: string) => /^\d/.test(marker)
205 while (i < lines.length) {
206 const m = ITEM.exec(lines[i])
207 if (m && m[1].length === first[1].length && isOrdered(m[2]) !== isOrdered(first[2])) break
208 if (m) {
209 const marker = /^\d/.test(m[2]) ? m[2] : CONFIG.glyphs.bullet
210 flat.push({ indent: m[1].length, item: { marker, text: m[3], children: [] } })
211 i++
212 } else if (!lines[i].trim()) {
213 let k = i
214 while (k < lines.length && !lines[k].trim()) k++
215 if (k >= lines.length || !ITEM.test(lines[k])) break
216 i = k
217 } else if (/^\s/.test(lines[i]) && !FENCE.test(lines[i])) {
218 flat[flat.length - 1].item.text += '\n' + lines[i].trim()
219 i++
220 } else {
221 break
222 }
223 }
224 blocks.push({ kind: 'list', items: nest(flat) })
225 continue
226 }
227
228 const isTable = TABLE.test(line)
229 const text: string[] = []
230 while (i < lines.length && lines[i].trim() && (text.length === 0 || (isTable ? TABLE.test(lines[i]) : !startsBlock(lines[i])))) {
231 text.push(lines[i++])
232 }
233 const raw = text.join('\n')
234 blocks.push(isTable ? (isCompleteTable(text) ? { kind: 'native', raw } : { kind: 'plain', text: raw }) : { kind: 'para', text: raw })
235 }
236
237 return blocks
238}
239
240const paint = (style: Style): Style =>
241 style.color ? { ...style, color: CONFIG.colors[style.color] ?? style.color } : style
242
243export const register: Register = on => {
244 const lastText = new Map<string, string>()
245
246 // Display-only: the committed message still reaches the ui.render hook below.
247 on('ui.render', { component: 'AssistantMessage' }, ($, e, next) => {
248 try {
249 const text: unknown = e.props.text
250 if (typeof text !== 'string') return next(e)
251 const t0 = performance.now()
252 const blocks = parseBlocks(text)
253 const t1 = performance.now()
254 if (blocks.length === 0) return next(e)
255
256 const { Box, Text, Markdown } = $.ui.resolve(e)
257 const { styles, glyphs, indent } = CONFIG
258
259 const inline = (src: string, base: Style = {}) => (
260 <Text {...paint(base)}>
261 {parseInline(src).map(run => {
262 const style: Style = {}
263 for (const mark of run.marks) Object.assign(style, styles[mark])
264 return Object.keys(style).length ? <Text {...paint(style)}>{run.text}</Text> : run.text
265 })}
266 </Text>
267 )
268
269 const item = (it: Item) => (
270 <Box flexDirection="row">
271 <Box flexShrink={0}>
272 <Text {...paint(styles.listMarker)}>{`${it.marker} `}</Text>
273 </Box>
274 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
275 {inline(it.text)}
276 {it.children.length > 0 ? (
277 <Box flexDirection="column" paddingLeft={indent}>
278 {it.children.map(child => item(child))}
279 </Box>
280 ) : null}
281 </Box>
282 </Box>
283 )
284
285 const block = (b: Block) => {
286 switch (b.kind) {
287 case 'heading':
288 return inline(b.text, styles.heading)
289 case 'para':
290 return inline(b.text)
291 case 'plain':
292 return <Text>{b.text}</Text>
293 case 'list':
294 return <Box flexDirection="column">{b.items.map(it => item(it))}</Box>
295 case 'quote':
296 return (
297 <Box flexDirection="column">
298 {b.lines.map(line => (
299 <Box flexDirection="row">
300 <Box flexShrink={0}>
301 <Text {...paint(styles.quoteBar)}>{`${glyphs.quoteBar} `}</Text>
302 </Box>
303 <Box flexGrow={1} flexShrink={1}>
304 {inline(line || ' ', styles.quote)}
305 </Box>
306 </Box>
307 ))}
308 </Box>
309 )
310 case 'native':
311 return b.raw.length > NATIVE_LIMIT ? <Text>{b.raw}</Text> : <Markdown text={b.raw} />
312 }
313 }
314
315 const tree = (
316 <Box flexDirection="row">
317 <Box flexShrink={0}>
318 <Text>{e.props.isFirstOfReply ? `${glyphs.reply} ` : ' '}</Text>
319 </Box>
320 <Box flexDirection="column" flexGrow={1} flexShrink={1} gap={1}>
321 {blocks.map(b => block(b))}
322 </Box>
323 </Box>
324 )
325
326 if (CONFIG.debugTiming) {
327 const t2 = performance.now()
328 const repeat = lastText.get(e.requestId) === text
329 lastText.set(e.requestId, text)
330 $.ui.log(
331 `md-render ${e.requestId} chars=${text.length} blocks=${blocks.length} parse=${(t1 - t0).toFixed(2)}ms build=${(t2 - t1).toFixed(2)}ms sameText=${repeat}`,
332 { to: 'debug' },
333 )
334 }
335 return tree
336 } catch {
337 return next(e)
338 }
339 })
340}
341