SLOPSHOPPER

midnight-neon

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

newrowscommand
v0.2.0no licenseupdated 2026-10-18fhva29/claude-code-mods/midnight-neon
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · midnight-neon
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /neon ⎿ midnight-neon: midnight-neon off. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-code-mods

Personal Claude Code mods, published as one local-style marketplace named heitor-mods.

Install

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.

Mods

ModWhat it doesCommand
next-liteSuggests 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-stackRoutes 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-toolsDraws each tool call as one compact line, or folds a whole turn into one "N steps this turn" line./lines
blast-radiusHolds 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-gateHolds git commit in a pane listing the staged files, flags files not edited this session and blocks AI attribution trailers.
replay-theaterSteps through the file edits Claude made in the last turn, one diff at a time./replay
usage-weatherA forecast band above the prompt: context fill, sparkline, rate limits, model and effort.
prompt-gitAppends the git branch and working-tree counts to the hint line under the prompt.
pixel-questReplaces the spinner with a pixel RPG scene for each tool, with XP, levels and achievements. No model calls./toons
brief-modeAppends 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-neonDraws 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.

Development

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.

Source 3 files
hooks/register.tsx 158 lines
1import { 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}
158
hooks/parse.ts 206 lines
1// 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}
206
types/index.d.ts 6 lines
1declare module 'claude-code' {
2  interface PluginState {
3    'midnight-neon': { isOn: boolean }
4  }
5}
6