SLOPSHOPPER

markdown-plus

Claude's replies with bold text in a warm palette and foldable headings styled by level

newrows
v0.5.0no licenseupdated 2026-10-15aseth0/my-claude-mods/markdown-plus
A shopper browsing a rack in a slop shop
README

markdown-plus

A Claude Code mod that changes how Claude's replies are drawn:

  • Colored bold text. Each bold span takes the next color of a warm palette (terracotta, honey, cinnamon and coral), chosen to read on both light and dark backgrounds. You can also pin a single color.
  • Foldable headings. Each heading gets a ▾ / ▸ button that folds its section and all its subsections. The folded state is kept per message.
  • A style per level. The terminal has a single font size, so each level is told apart by color, case, underline, italics and a rule beneath:
LevelStyle
#UPPERCASE, terracotta, ━ rule
##honey, ─ rule
###cinnamon, underlined
####coral
#####cinnamon, italic
######cinnamon, italic and dimmed

Sub-levels are indented from the reply's top heading level.

Code blocks, tables, quotes and the rest of the Markdown are still drawn by Claude Code's own renderer. Replies with no bold text and no headings are left untouched.

Works in the terminal, the desktop app, VS Code and on mobile.

Requirements

  • Claude Code 2.1.286 or later.
  • Function hooks enabled. They are an early-access feature, so the API may change between releases without notice.

Installation

  1. Clone the repository:
   git clone https://github.com/aseth0/my-claude-mods.git ~/my-claude-mods
  1. Add the mod's folder to ~/.claude/settings.json and enable function hooks:
   {
     "env": {
       "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/my-claude-mods/markdown-plus",
       "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1"
     }
   }

If you already load other mods, separate the paths with :.

  1. Fully restart Claude Code. /reload-plugins does not re-read environment variables.

If settings.json has a syntax error (a missing comma, for example), Claude Code silently drops every mod.

Configuration

OptionValuesDefaultWhat it does
palettewarm, singlewarmwarm cycles through the four colors; single always uses color.
colortheme key, color name or hex#E07A5FThe bold color with the single palette.

mod-switch support

If you load a mod-switch mod that keeps the list of disabled mods in its mod-switch.disabled state, markdown-plus steps aside while it is on that list. Without that mod, markdown-plus works the same.

Development

claude plugin validate markdown-plus
claude plugin test markdown-plus

Claude Code generates the .claude-plugin/types/ folder (TypeScript declarations and tsconfig.json) the first time it loads the mod, so it is not in the repository. Until then, your editor will not find the claude-code types.

  • hooks/parse.ts splits a reply into sections and blocks, and separates prose with bold text from the rest of the Markdown.
  • hooks/register.tsx draws the headings, the fold buttons and the colored bold text.

Changes

See CHANGELOG.md.

Source 3 files
hooks/register.tsx 167 lines
1import { atom, memberOf, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { parseBlocks, parseSections, visibleSections } from './parse'
5import type { Section } from './parse'
6
7// mod-switch: while its /mods panel has this mod off, every hook steps aside.
8declare module 'claude-code' {
9  interface PluginState {
10    'mod-switch': { disabled: string[] }
11  }
12}
13const SWITCH = { plugin: 'mod-switch', key: 'disabled' } as const
14async function isSwitchedOff($: EngineInterface) {
15  const off = await $.state.get(SWITCH).then(s => s.value, () => undefined)
16  return Array.isArray(off) && off.includes('markdown-plus')
17}
18
19// Terracotta, honey, cinnamon, coral: mid tones that read on light and dark backgrounds.
20const WARM = ['#E07A5F', '#E9A23B', '#C8875E', '#D96C75']
21
22// How each heading level reads: the terminal has one font size, so size is
23// told by case, weight, underline, a rule beneath and the space around it.
24type HeadingStyle = {
25  color: string
26  upper?: boolean
27  underline?: boolean
28  italic?: boolean
29  dim?: boolean
30  rule?: string
31}
32const HEADING: Record<number, HeadingStyle> = {
33  1: { color: '#E07A5F', upper: true, rule: '━' },
34  2: { color: '#E9A23B', rule: '─' },
35  3: { color: '#C8875E', underline: true },
36  4: { color: '#D96C75' },
37  5: { color: '#C8875E', italic: true },
38  6: { color: '#C8875E', italic: true, dim: true },
39}
40
41// Per message: the indices of its collapsed headings.
42const collapsed = atom({ plugin: 'markdown-plus', key: 'collapsed' } as const, [])
43
44const HAS_HEADING = /^#{1,6}\s/m
45
46export const register: Register = (on, options) => {
47  const single = typeof options.color === 'string' && options.color ? options.color : WARM[0]
48  const palette = options.palette === 'single' ? [single] : WARM
49
50  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
51    if (await isSwitchedOff($)) return next(e)
52    const { text } = e.props
53    // Nothing bold and no headings, or a summary row: the engine draws it as always.
54    if (e.props.isSummary || (!text.includes('**') && !HAS_HEADING.test(text))) return next(e)
55
56    const { intro, sections } = parseSections(text)
57    const needsUs = sections.length > 0 || parseBlocks(text).some(b => b.kind === 'prose')
58    if (!needsUs) return next(e)
59
60    const { Box, Text, Markdown, Button } = $.ui.resolve(e)
61    const bullet = e.surface === 'terminal' && e.props.isFirstOfReply
62    const mine = memberOf(collapsed, e)
63    const folded = await read($, mine)
64    const shown = visibleSections(sections, folded)
65    // Sub-levels step in from the reply's top heading level.
66    const top = Math.min(...sections.map(s => s.level))
67    const indent = (s: Section) => Math.min(s.level - top, 3) * 2
68
69    // Each bold in the reply takes the next color of the palette.
70    let nth = 0
71    const nextColor = () => palette[nth++ % palette.length]
72
73    const prose = (md: string) =>
74      parseBlocks(md).map(block =>
75        block.kind === 'markdown' ? (
76          <Markdown text={block.text} />
77        ) : (
78          <Box flexDirection="column">
79            {block.lines.map(line => (
80              // The marker sits in its own column so wrapped lines align under the text.
81              <Box flexDirection="row" alignItems="flex-start" paddingLeft={line.indent * 2}>
82                {line.marker ? <Text>{`${line.marker} `}</Text> : null}
83                <Box flexShrink={1}>
84                <Text>
85                  {line.spans.map(span =>
86                    span.kind === 'bold' ? (
87                      <Text bold color={nextColor()}>{span.text}</Text>
88                    ) : span.kind === 'code' ? (
89                      <Text color="permission">{span.text}</Text>
90                    ) : (
91                      span.text
92                    ),
93                  )}
94                </Text>
95                </Box>
96              </Box>
97            ))}
98          </Box>
99        ),
100      )
101
102    const toggle = (i: number) => () =>
103      update($, mine, list => (list.includes(i) ? list.filter(n => n !== i) : [...list, i]))
104
105    const heading = (s: Section, i: number) => {
106      const st = HEADING[s.level] ?? HEADING[6]!
107      const plain = s.title.replace(/\*\*|`/g, '')
108      const title = st.upper ? plain.toUpperCase() : plain
109      const isFolded = folded.includes(i)
110      return (
111        <Box flexDirection="column">
112          <Box key={`row${i}`} flexDirection="row">
113            <Button
114              key={`h${i}`}
115              plain
116              label={isFolded ? '▸' : '▾'}
117              hover={{ color: st.color }}
118              onPress={toggle(i)}
119            />
120            <Text> </Text>
121            <Text
122              bold={!st.dim}
123              color={st.color}
124              underline={st.underline}
125              italic={st.italic}
126              dimColor={st.dim}
127            >
128              {title}
129            </Text>
130            {isFolded ? <Text dimColor> …</Text> : null}
131          </Box>
132          {st.rule && !isFolded ? (
133            <Text color={st.color} dimColor={s.level > 1}>
134              {st.rule.repeat(Math.min(title.length + 2, 72))}
135            </Text>
136          ) : null}
137        </Box>
138      )
139    }
140
141    const body = (
142      <Box flexDirection="column" gap={1} flexGrow={1}>
143        {intro ? prose(intro) : null}
144        {sections.map((s, i) => {
145          if (!shown[i]) return null
146          const isFolded = folded.includes(i)
147          return (
148            <Box flexDirection="column" gap={1} paddingLeft={indent(s)} marginTop={s.level === 1 && i > 0 ? 1 : 0}>
149              {heading(s, i)}
150              {!isFolded && s.body ? prose(s.body) : null}
151            </Box>
152          )
153        })}
154      </Box>
155    )
156
157    if (!bullet) return body
158
159    return (
160      <Box flexDirection="row">
161        <Text>{'⏺ '}</Text>
162        {body}
163      </Box>
164    )
165  })
166}
167
hooks/parse.ts 122 lines
1// Splits an assistant reply into blocks: plain prose blocks holding **bold**
2// (drawn by the mod with colored spans) and everything else (code fences,
3// tables, headings, quotes), handed back to the surface's own Markdown.
4
5export type Span = { kind: 'text' | 'bold' | 'code'; text: string }
6export type Line = { marker: string; indent: number; spans: Span[] }
7export type Block = { kind: 'markdown'; text: string } | { kind: 'prose'; lines: Line[] }
8
9const LIST = /^(\s*)([-*+•]|\d+[.)])\s+(.*)$/
10const INLINE = /(\*\*[^*\n]+?\*\*|`[^`\n]+`|\[[^\]\n]+\]\([^)\n]+\))/
11
12export function parseInline(text: string): Span[] {
13  const spans: Span[] = []
14  for (const part of text.split(INLINE)) {
15    if (part === '') continue
16    if (part.startsWith('**') && part.endsWith('**') && part.length > 4) {
17      spans.push({ kind: 'bold', text: part.slice(2, -2) })
18    } else if (part.startsWith('`') && part.endsWith('`') && part.length > 2) {
19      spans.push({ kind: 'code', text: part.slice(1, -1) })
20    } else if (part.startsWith('[')) {
21      const m = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(part)
22      spans.push({ kind: 'text', text: m ? `${m[1]} (${m[2]})` : part })
23    } else {
24      spans.push({ kind: 'text', text: part })
25    }
26  }
27  return spans
28}
29
30function isProse(lines: string[]): boolean {
31  return lines.every(l => !/^\s*(#|>|\||```|~~~)/.test(l) && !/^\s*[-*_]{3,}\s*$/.test(l))
32}
33
34// `nested`: the item hangs under a paragraph line of its own block, so it
35// steps in one level instead of sitting flush with the paragraph.
36function toLine(raw: string, nested = false): Line {
37  const m = LIST.exec(raw)
38  if (!m) return { marker: '', indent: 0, spans: parseInline(raw.trim()) }
39  const [, lead = '', mark = '', rest = ''] = m
40  const marker = /\d/.test(mark) ? mark : '•'
41  return { marker, indent: Math.floor(lead.length / 2) + (nested ? 1 : 0), spans: parseInline(rest) }
42}
43
44export function parseBlocks(text: string): Block[] {
45  // Chunks separated by blank lines, a fenced code block always one chunk.
46  const chunks: string[][] = []
47  let cur: string[] = []
48  let inFence = false
49  for (const line of text.split('\n')) {
50    if (/^\s*(```|~~~)/.test(line)) inFence = !inFence
51    if (!inFence && line.trim() === '') {
52      if (cur.length) chunks.push(cur)
53      cur = []
54    } else {
55      cur.push(line)
56    }
57  }
58  if (cur.length) chunks.push(cur)
59
60  const blocks: Block[] = []
61  for (const chunk of chunks) {
62    const joined = chunk.join('\n')
63    if (joined.includes('**') && isProse(chunk)) {
64      // Continuation lines of a paragraph join the line before them.
65      const lines: string[] = []
66      for (const l of chunk) {
67        if (lines.length && !LIST.test(l)) lines[lines.length - 1] += ' ' + l.trim()
68        else lines.push(l)
69      }
70      let underParagraph = false
71      blocks.push({
72        kind: 'prose',
73        lines: lines.map(l => {
74          const isItem = LIST.test(l)
75          if (!isItem) underParagraph = true
76          return toLine(l, isItem && underParagraph)
77        }),
78      })
79    } else {
80      const last = blocks[blocks.length - 1]
81      if (last?.kind === 'markdown') last.text += '\n\n' + joined
82      else blocks.push({ kind: 'markdown', text: joined })
83    }
84  }
85  return blocks
86}
87
88// A reply cut at its markdown headings (never inside a code fence): the text
89// before the first heading, then one section per heading with its body.
90export type Section = { level: number; title: string; body: string }
91
92const HEADING = /^(#{1,6})\s+(.*?)\s*#*\s*$/
93
94export function parseSections(text: string): { intro: string; sections: Section[] } {
95  const intro: string[] = []
96  const sections: { level: number; title: string; body: string[] }[] = []
97  let inFence = false
98  for (const line of text.split('\n')) {
99    if (/^\s*(```|~~~)/.test(line)) inFence = !inFence
100    const m = inFence ? null : HEADING.exec(line)
101    const last = sections[sections.length - 1]
102    if (m) sections.push({ level: (m[1] ?? '#').length, title: m[2] ?? '', body: [] })
103    else if (last) last.body.push(line)
104    else intro.push(line)
105  }
106  return {
107    intro: intro.join('\n').trim(),
108    sections: sections.map(s => ({ ...s, body: s.body.join('\n').trim() })),
109  }
110}
111
112// Which sections show: one under a collapsed heading of a lower level is hidden.
113export function visibleSections(sections: Section[], collapsed: readonly number[]): boolean[] {
114  let hideBelow = Infinity
115  return sections.map((s, i) => {
116    if (s.level <= hideBelow) hideBelow = Infinity
117    const shown = hideBelow === Infinity
118    if (shown && collapsed.includes(i)) hideBelow = s.level
119    return shown
120  })
121}
122
types/index.d.ts 8 lines
1export type Collapsed = number[]
2
3declare module 'claude-code' {
4  interface PluginState {
5    'markdown-plus': { collapsed: StateFamily<Collapsed> }
6  }
7}
8