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

A Claude Code mod that changes how Claude's replies are drawn:
▾ / ▸ button that folds its section and all its subsections. The folded state is kept per message.| Level | Style |
|---|---|
# | 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.
git clone https://github.com/aseth0/my-claude-mods.git ~/my-claude-mods
~/.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 :.
/reload-plugins does not re-read environment variables.If
settings.jsonhas a syntax error (a missing comma, for example), Claude Code silently drops every mod.
| Option | Values | Default | What it does |
|---|---|---|---|
palette | warm, single | warm | warm cycles through the four colors; single always uses color. |
color | theme key, color name or hex | #E07A5F | The bold color with the single palette. |
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.
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.See CHANGELOG.md.
hooks/register.tsx 167 lines1import { 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}
167hooks/parse.ts 122 lines1// 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}
122types/index.d.ts 8 lines1export type Collapsed = number[]
2
3declare module 'claude-code' {
4 interface PluginState {
5 'markdown-plus': { collapsed: StateFamily<Collapsed> }
6 }
7}
8