SLOPSHOPPER

md-render

Redraws assistant messages as styled markdown.

newrows
★ 4v0.1.0MITupdated 2026-10-04deepskyblue86/claude-markdown-mod
A shopper browsing a rack in a slop shop
README

md-render

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".

MarkdownRendering
# Headingbold purple
boldbold orange
*italic*italic amber
*bold italic*bold italic
strikestrikethrough
` code `green
- item, 1. itemorange markers, nested lists indented
> quotedim │ bar, italic text
fenced code, tablesdrawn by Claude Code's own renderer (syntax highlighting, wrapping)
textthe text followed by the dim address
images, anything elseplain 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.

Install

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

Configure

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.

Develop

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.

License

MIT. See LICENSE.

Source 1 files
hooks/register.tsx 341 lines
1import 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