Draws assistant replies with a dark neon palette: highlighted headers, colored bullets, quote bars and inline styles. /neon toggles it.

Personal Claude Code mods, published as one local-style marketplace named heitor-mods.
Requires a Claude Code build with mod (function hooks) support.
1. Add the marketplace. The repository is private, so gh auth login (or any git credential that can read it) must work first.
claude plugin marketplace add fhva29/claude-code-mods
Or clone it and add the folder; Claude Code then reads the mods straight from it, so an edit plus /reload-plugins is enough, with no reinstall:
git clone https://github.com/fhva29/claude-code-mods ~/.claude/mods
claude plugin marketplace add ~/.claude/mods
2. Install the mods you want (user scope):
claude plugin install next-lite@heitor-mods
claude plugin install tidy-tools@heitor-mods
# ...or all of them:
for m in blast-radius commit-gate model-stack next-lite pixel-quest prompt-git \
replay-theater tidy-tools usage-weather brief-mode; do
claude plugin install "$m@heitor-mods"
done
3. Reload inside Claude Code with /reload-plugins.
Options (userConfig) are set in /plugin → pick the mod → configure, then /reload-plugins. They are stored in ~/.claude/settings.json under pluginConfigs.
| Mod | What it does | Command | |
|---|---|---|---|
next-lite | Suggests up to three next prompts above the input (press 1/2/3 to fill the prompt box, 0 to dismiss; the top one is also Tab ghost text). Sends a ~1–2k token digest of the turn to a small model instead of forking the whole session, so its cost does not grow with the conversation. Replacement for next-steps. Options: mode (auto/manual), model, minAnswerChars, suggestSkills. | /next | |
model-stack | Routes the model and effort of the main chat turn and of each subagent with TypeSafe's Jev, and shows the roster, decisions and agent graph in a pane. Needs TYPESAFE_API_KEY in the environment. | /stack | |
tidy-tools | Draws each tool call as one compact line, or folds a whole turn into one "N steps this turn" line. | /lines | |
blast-radius | Holds risky shell commands (rm -rf, git reset --hard, git clean, force push, migrations) and shows what they would change, with Proceed and Cancel buttons. | ||
commit-gate | Holds git commit in a pane listing the staged files, flags files not edited this session and blocks AI attribution trailers. | ||
replay-theater | Steps through the file edits Claude made in the last turn, one diff at a time. | /replay | |
usage-weather | A forecast band above the prompt: context fill, sparkline, rate limits, model and effort. | ||
prompt-git | Appends the git branch and working-tree counts to the hint line under the prompt. | ||
pixel-quest | Replaces the spinner with a pixel RPG scene for each tool, with XP, levels and achievements. No model calls. | /toons | |
brief-mode | Appends a short-answer style section to the system prompt: answer or next action first, numbered steps, lists capped at five, no preamble, recap or closers. Edit hooks/rules.ts to tune it. | ||
midnight-neon | Draws assistant replies with a dark neon palette: filled h1 banner, colored h2–h6, colored bullets, quote bars, highlighted bold, italic, inline code and links. Fences and tables keep the engine's renderer. Not installed by default; /neon flips it per session, `claude plugin enable\ | disable` flips it for good. | /neon |
_archive holds mods that are kept but not in use (branch-hud); they are not listed in the marketplace.
Each mod is a folder with .claude-plugin/plugin.json, hooks/hooks.json and a hooks module (register.ts or register.tsx). Check one with:
claude plugin validate <mod>
claude plugin test <mod>
claude plugin test does not type-check; run tsc against the SDK declarations Claude Code writes to <mod>/.claude-plugin/types/ as well.
next-lite builds on next-steps (MIT, Thariq Shihipar): it reuses the text sanitizer and the reply parsing, and replaces the session fork with a small digest.
hooks/register.tsx 158 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { parse, type Block, type Span } from './parse'
5
6// MARK: Palette
7
8const PALETTE = {
9 accent: '#FF79C6',
10 h1Text: '#F8F8F2',
11 h1Fill: '#7C3AED',
12 h2: '#FF79C6',
13 h3: '#8BE9FD',
14 h4: '#50FA7B',
15 bold: '#FFB86C',
16 italic: '#BD93F9',
17 code: '#F1FA8C',
18 codeFill: '#2B2D3A',
19 link: '#8BE9FD',
20 quoteBar: '#6272A4',
21 quoteText: '#9AA5CE',
22 rule: '#44475A',
23} as const
24
25const BULLETS = ['●', '◆', '▸']
26const RULE_WIDTH = 40
27const GUTTER = 6
28const FALLBACK_COLUMNS = 100
29
30const headingColor = (level: number) => (level === 2 ? PALETTE.h2 : level === 3 ? PALETTE.h3 : PALETTE.h4)
31const plainText = (spans: Span[]) => spans.map(span => span.text).join('')
32
33// MARK: State
34
35// The quick switch behind /neon; disabling the plugin itself is the coarse one.
36const isOn = atom({ plugin: 'midnight-neon', key: 'isOn' } as const, true)
37const STORE_KEY = 'isOn'
38
39// MARK: Hooks
40
41export const register: Register = on => {
42 on('session.start', async ($, e, next) => {
43 await $.command.register({
44 name: 'neon',
45 description: 'Turn the midnight-neon reply style on or off',
46 argumentHint: '[on|off]',
47 })
48 const saved = await $.store.get(STORE_KEY)
49 if (typeof saved === 'boolean') await update($, isOn, () => saved)
50
51 return next(e)
52 })
53
54 on('command.run', { command: 'neon' }, async ($, e) => {
55 const arg = e.args.trim().toLowerCase()
56 const current = await read($, isOn)
57 const next = arg === 'on' ? true : arg === 'off' ? false : !current
58 await update($, isOn, () => next)
59 await $.store.set(STORE_KEY, next)
60
61 return { text: `midnight-neon ${next ? 'on' : 'off'}.` }
62 })
63
64 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
65 // A summary keeps the engine's faint "· summary" mark, which a tree of the mod's own would drop.
66 if (e.surface !== 'terminal' || e.props.isSummary || !(await read($, isOn))) return next(e)
67 const blocks = parse(e.props.text).filter((block, at, all) => block.kind !== 'gap' || (at > 0 && at < all.length - 1))
68 if (blocks.every(block => block.kind === 'gap')) return next(e)
69
70 const { Box, Text, Link, Code, Markdown } = $.ui.resolve(e)
71 const columns = e.viewport?.columns ?? FALLBACK_COLUMNS
72
73 const drawSpan = (span: Span) => {
74 switch (span.kind) {
75 case 'code':
76 return <Text color={PALETTE.code} backgroundColor={PALETTE.codeFill}>{` ${span.text} `}</Text>
77 case 'bold':
78 return <Text bold color={PALETTE.bold}>{span.text}</Text>
79 case 'italic':
80 return <Text italic color={PALETTE.italic}>{span.text}</Text>
81 case 'boldItalic':
82 return <Text bold italic color={PALETTE.bold}>{span.text}</Text>
83 case 'strike':
84 return <Text strikethrough dimColor>{span.text}</Text>
85 case 'link':
86 return (
87 <Link href={span.href ?? span.text}>
88 <Text underline color={PALETTE.link}>{span.text}</Text>
89 </Link>
90 )
91 default:
92 return <Text>{span.text}</Text>
93 }
94 }
95
96 const drawHeading = (level: number, spans: Span[]) => {
97 const text = plainText(spans)
98 if (level === 1) return <Text bold color={PALETTE.h1Text} backgroundColor={PALETTE.h1Fill}>{` ${text} `}</Text>
99 const mark = level === 2 ? '▌ ' : level === 3 ? '▸ ' : ''
100 return <Text bold color={headingColor(level)}>{`${mark}${text}`}</Text>
101 }
102
103 const drawBlock = (block: Block) => {
104 switch (block.kind) {
105 case 'heading':
106 return drawHeading(block.level, block.spans)
107 case 'paragraph':
108 return <Text>{block.spans.map(drawSpan)}</Text>
109 case 'item': {
110 const isOrdered = /^\d/.test(block.marker)
111 const bullet = isOrdered ? block.marker : (BULLETS[block.depth % BULLETS.length] ?? '●')
112 return (
113 <Box flexDirection="row">
114 <Text>{' '.repeat(block.depth * 2)}</Text>
115 <Text bold color={PALETTE.accent}>{`${bullet} `}</Text>
116 <Box flexDirection="column" flexGrow={1}>
117 <Text>{block.spans.map(drawSpan)}</Text>
118 </Box>
119 </Box>
120 )
121 }
122 case 'quote':
123 return (
124 <Box flexDirection="row">
125 <Text color={PALETTE.quoteBar}>{'▎ '}</Text>
126 <Box flexDirection="column" flexGrow={1}>
127 <Text italic color={PALETTE.quoteText}>{plainText(block.spans)}</Text>
128 </Box>
129 </Box>
130 )
131 case 'rule':
132 return <Text color={PALETTE.rule}>{'─'.repeat(Math.min(RULE_WIDTH, columns - GUTTER))}</Text>
133 case 'fence':
134 return <Code source={block.source} language={block.language === '' ? undefined : block.language} />
135 case 'table':
136 return <Markdown text={block.text} />
137 default:
138 return <Text>{' '}</Text>
139 }
140 }
141
142 // A blank line above every heading, and above any block that followed one in the source.
143 const rows = blocks.flatMap((block, at) => {
144 if (block.kind === 'gap') return []
145 const afterGap = blocks[at - 1]?.kind === 'gap'
146 const isSeparated = at > 0 && (block.kind === 'heading' || afterGap)
147 return [<Box flexDirection="column" marginTop={isSeparated ? 1 : 0}>{drawBlock(block)}</Box>]
148 })
149
150 return (
151 <Box flexDirection="row">
152 <Text bold color={PALETTE.accent}>{e.props.isFirstOfReply ? '● ' : ' '}</Text>
153 <Box flexDirection="column" flexGrow={1}>{rows}</Box>
154 </Box>
155 )
156 })
157}
158hooks/parse.ts 206 lines1// MARK: Shape
2
3export type SpanKind = 'plain' | 'code' | 'bold' | 'italic' | 'boldItalic' | 'strike' | 'link'
4
5export type Span = { text: string; kind: SpanKind; href?: string }
6
7export type Block =
8 | { kind: 'heading'; level: number; spans: Span[] }
9 | { kind: 'paragraph'; spans: Span[] }
10 | { kind: 'item'; depth: number; marker: string; spans: Span[] }
11 | { kind: 'quote'; spans: Span[] }
12 | { kind: 'rule' }
13 | { kind: 'fence'; language: string; source: string }
14 | { kind: 'table'; text: string }
15 | { kind: 'gap' }
16
17// MARK: Inline
18
19const ESCAPABLE = /[\\`*_{}[\]()#+\-.!|~>]/
20const BARE_URL = /^https?:\/\/[^\s<>]+/
21const TRAILING_PUNCTUATION = /[.,;:!?)\]]+$/
22
23const RULES: { pattern: RegExp; kind: SpanKind; group: number }[] = [
24 { pattern: /^(`+)(.+?)\1(?!`)/, kind: 'code', group: 2 },
25 { pattern: /^\*\*\*(.+?)\*\*\*/, kind: 'boldItalic', group: 1 },
26 { pattern: /^\*\*(.+?)\*\*/, kind: 'bold', group: 1 },
27 { pattern: /^__(.+?)__/, kind: 'bold', group: 1 },
28 { pattern: /^~~(.+?)~~/, kind: 'strike', group: 1 },
29 { pattern: /^\*([^*\s][^*]*?)\*/, kind: 'italic', group: 1 },
30]
31const UNDERSCORE_ITALIC = /^_([^_\s][^_]*?)_/
32const LINK = /^\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/
33
34export function inline(source: string): Span[] {
35 const spans: Span[] = []
36 let plain = ''
37 let at = 0
38
39 const flush = () => {
40 if (plain !== '') spans.push({ text: plain, kind: 'plain' })
41 plain = ''
42 }
43
44 while (at < source.length) {
45 const rest = source.slice(at)
46 const char = rest[0] ?? ''
47 const next = rest[1] ?? ''
48
49 if (char === '\\' && ESCAPABLE.test(next)) {
50 plain += next
51 at += 2
52 continue
53 }
54
55 const link = LINK.exec(rest)
56 if (link) {
57 flush()
58 spans.push({ text: link[1] ?? '', kind: 'link', href: link[2] ?? '' })
59 at += link[0].length
60 continue
61 }
62
63 const rule = RULES.map(r => ({ r, m: r.pattern.exec(rest) })).find(hit => hit.m !== null)
64 if (rule?.m) {
65 flush()
66 const text = rule.m[rule.r.group] ?? ''
67 spans.push({ text: rule.r.kind === 'code' ? text.trim() : text, kind: rule.r.kind })
68 at += rule.m[0].length
69 continue
70 }
71
72 // An underscore only opens emphasis at a word edge, so snake_case stays plain.
73 const underscore = char === '_' && !/\w/.test(source[at - 1] ?? ' ') ? UNDERSCORE_ITALIC.exec(rest) : null
74 if (underscore) {
75 flush()
76 spans.push({ text: underscore[1] ?? '', kind: 'italic' })
77 at += underscore[0].length
78 continue
79 }
80
81 const url = BARE_URL.exec(rest)?.[0]
82 if (url !== undefined) {
83 const href = url.replace(TRAILING_PUNCTUATION, '')
84 flush()
85 spans.push({ text: href, kind: 'link', href })
86 at += href.length
87 continue
88 }
89
90 plain += char
91 at += 1
92 }
93
94 flush()
95 return spans
96}
97
98// MARK: Blocks
99
100const FENCE = /^\s*(`{3,}|~{3,})\s*([\w+#.-]*)/
101const HEADING = /^(#{1,6})\s+(.*?)\s*#*\s*$/
102const RULE = /^\s*([-*_])(\s*\1){2,}\s*$/
103const QUOTE = /^\s*>\s?(.*)$/
104const ITEM = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/
105const TABLE_ROW = /^\s*\|.*\|\s*$/
106
107type Draft =
108 | { kind: 'heading'; level: number; source: string }
109 | { kind: 'paragraph'; source: string }
110 | { kind: 'item'; depth: number; marker: string; source: string }
111 | { kind: 'quote'; source: string }
112 | Exclude<Block, { spans: Span[] }>
113
114const startsBlock = (line: string) =>
115 FENCE.test(line) || HEADING.test(line) || RULE.test(line) || QUOTE.test(line) || ITEM.test(line) || TABLE_ROW.test(line)
116
117export function parse(text: string): Block[] {
118 const lines = text.split('\n')
119 const drafts: Draft[] = []
120 let at = 0
121
122 while (at < lines.length) {
123 const line = lines[at] ?? ''
124 at += 1
125
126 if (line.trim() === '') {
127 if (drafts.at(-1)?.kind !== 'gap') drafts.push({ kind: 'gap' })
128 continue
129 }
130
131 const fence = FENCE.exec(line)
132 if (fence) {
133 const mark = fence[1] ?? '```'
134 const body: string[] = []
135 while (at < lines.length && !(lines[at] ?? '').trim().startsWith(mark)) {
136 body.push(lines[at] ?? '')
137 at += 1
138 }
139 at += 1
140 drafts.push({ kind: 'fence', language: fence[2] ?? '', source: body.join('\n') })
141 continue
142 }
143
144 if (TABLE_ROW.test(line)) {
145 const rows = [line]
146 while (at < lines.length && TABLE_ROW.test(lines[at] ?? '')) {
147 rows.push(lines[at] ?? '')
148 at += 1
149 }
150 drafts.push({ kind: 'table', text: rows.join('\n') })
151 continue
152 }
153
154 const heading = HEADING.exec(line)
155 if (heading) {
156 drafts.push({ kind: 'heading', level: (heading[1] ?? '#').length, source: heading[2] ?? '' })
157 continue
158 }
159
160 if (RULE.test(line)) {
161 drafts.push({ kind: 'rule' })
162 continue
163 }
164
165 const quote = QUOTE.exec(line)
166 if (quote) {
167 drafts.push({ kind: 'quote', source: quote[1] ?? '' })
168 continue
169 }
170
171 const item = ITEM.exec(line)
172 if (item) {
173 drafts.push({
174 kind: 'item',
175 depth: Math.floor((item[1] ?? '').length / 2),
176 marker: item[2] ?? '-',
177 source: item[3] ?? '',
178 })
179 continue
180 }
181
182 // A plain line continues the paragraph or list item above it.
183 const previous = drafts.at(-1)
184 if (previous && (previous.kind === 'paragraph' || previous.kind === 'item') && !startsBlock(line)) {
185 previous.source += ` ${line.trim()}`
186 continue
187 }
188 drafts.push({ kind: 'paragraph', source: line.trim() })
189 }
190
191 return drafts.map((draft): Block => {
192 switch (draft.kind) {
193 case 'heading':
194 return { kind: 'heading', level: draft.level, spans: inline(draft.source) }
195 case 'paragraph':
196 return { kind: 'paragraph', spans: inline(draft.source) }
197 case 'item':
198 return { kind: 'item', depth: draft.depth, marker: draft.marker, spans: inline(draft.source) }
199 case 'quote':
200 return { kind: 'quote', spans: inline(draft.source) }
201 default:
202 return draft
203 }
204 })
205}
206types/index.d.ts 6 lines1declare module 'claude-code' {
2 interface PluginState {
3 'midnight-neon': { isOn: boolean }
4 }
5}
6