SLOPSHOPPER

reply-style

Colored headers, bold text and list bullets, and shaded code blocks in Claude's replies, and a navy row behind your prompts

newrowstoast
v0.4.1no licenseupdated 2026-10-14jackyyeh5111/dotfiles/claude/mods/reply-style
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · reply-style
❯ 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 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Your message
❯ fix the failing auth test and add an audit log call
README

dotfiles

Personal dotfiles + provisioning scripts for a brand new machine:

  • setup.sh — Debian/Ubuntu (apt)
  • setup.mac.sh — macOS (Homebrew)

Quickstart

git clone <this-repo-url> ~/dotfiles
cd ~/dotfiles

./setup.sh        # Debian/Ubuntu
./setup.mac.sh    # macOS

Both scripts are organized into one section per tool, check whether each tool is already installed before doing work again (safe to re-run), and back up (never delete) any existing config file before replacing it with a symlink. If any step fails, the script prints exactly which step, command, and line failed and stops — fix the issue and re-run it; completed steps are skipped automatically.

What setup.sh installs

  • Base packages: python3/pip, vim, htop, tmux, git, openssh-server, build tools
  • ripgrep, neofetch, fzf, bat, fd, xclip, astyle, eza
  • zsh + oh-my-zsh + zsh-autosuggestions
  • starship prompt, zoxide
  • Rust (rustup)
  • virtualenvwrapper
  • Docker (official convenience script)
  • Neovim (latest official release build, not the apt package)
  • yazi (latest official release build)
  • herdr
  • Claude Code
  • Symlinks all dotfiles into place (.zshrc, .vimrc, .tmux.conf, starship.toml, ghostty.config, herdr_config.toml, nvim/, yazi/*)
  • git config --global core.editor nvim
  • Best-effort bootstrap of nvim plugins (lazy.nvim) and yazi plugins (ya pkg install)

What setup.mac.sh installs

Same set, via Homebrew, minus what macOS already provides:

  • Homebrew itself (and the Xcode Command Line Tools check that precedes it)
  • Base packages: python3, git, tmux, vim, htop, wget
  • ripgrep, fastfetch, fzf, bat, fd, astyle, eza
  • oh-my-zsh + zsh-autosuggestions (the brew formula, which is what .zshrc.mac sources)
  • starship prompt, zoxide
  • Rust (rustup, so ~/.cargo/env exists for .zshrc.mac)
  • virtualenvwrapper (brew formula, so it lands in $(brew --prefix)/bin)
  • Docker Desktop (cask)
  • Neovim, yazi, herdr (brew), Claude Code (install script)
  • Ghostty (cask — unlike Linux, it's automated here)
  • Symlinks all dotfiles into place, with .zshrc.mac as ~/.zshrc
  • git config --global core.editor nvim
  • Best-effort bootstrap of nvim and yazi plugins

Differences from the Linux script, all deliberate:

  • No zsh, build tools, curl/unzip/gpg or xclip — macOS ships zsh and the clipboard is pbcopy; compilers come from the Command Line Tools.
  • No openssh-server — macOS has sshd built in, just toggled off.
  • No bat→batcat / fd→fdfind renames, so no ~/.local/bin shims.
  • neofetch is gone from homebrew-core (archived upstream); fastfetch takes its place.
  • The script warns at the end if .zshrc.mac's hardcoded /usr/local paths or /Users/jackyyeh home directory don't match this machine (they don't on Apple Silicon).

Manual steps

A few things are deliberately not automated because they're either interactive, machine/version-specific, or risky to guess at:

  • Default shell — if chsh didn't run automatically (e.g. non-interactive session), run: ``bash chsh -s $(command -v zsh) `` then log out and back in.
  • Docker group (Linux) — setup.sh adds you to the docker group, but that only takes effect after you log out/in (or run newgrp docker).
  • Docker Desktop (macOS) — setup.mac.sh installs the cask, but you must launch it once from /Applications before the docker CLI exists.
  • Remote Login (macOS) — macOS ships sshd but leaves it off; enable it in System Settings → General → Sharing → Remote Login.
  • Go — install from https://go.dev/dl/ (download the linux tarball and extract to /usr/local/go, matching the PATH entry already in .zshrc.linux).
  • Node.js — install nvm, then: ``bash nvm install --lts ` Note: .zshrc.linux also has a hardcoded PATH entry pointing at a specific past node version directory — harmless if it doesn't exist, just update it to match whatever nvm installs if you want node/npm always on PATH` without loading nvm.
  • Codex CLI — install per OpenAI's current instructions for the Codex CLI; left out here since the install method changes independently of this repo.
  • Ghostty (terminal app) — Linux only: install via your distro's package manager, snap, or from https://ghostty.org/download; setup.sh only symlinks ghostty.config into ~/.config/ghostty/config. On macOS setup.mac.sh installs the cask for you.
  • SSH key for GitHub — generate one and add it to your GitHub account if you'll be pushing over SSH: ``bash ssh-keygen -t ed25519 -C "your_email@example.com" ``
  • First nvim launch — setup.sh best-effort bootstraps plugins headlessly; if that step warned, just open nvim once and let lazy.nvim finish, then Mason will install LSP servers as configured in nvim/lua/plugins.lua.
Source 2 files
hooks/register.tsx 139 lines
1import type { Register } from 'claude-code'
2
3import { type Inline, type ListItem, parseInline, splitBlocks, splitFences } from './parse'
4
5// Panel color behind code blocks; pick one that sits a shade off your terminal background.
6const CODE_BG = '#353b45'
7// Behind your own prompts: a muted navy, as bright as the grey panels but
8// tinted toward the bullet blue, so it stands out by hue rather than glare.
9const PROMPT_BG = '#2f4260'
10// Copy chip: a raised One Dark grey at rest, the palette's blue on hover.
11const CHIP = { bg: '#4b5263', fg: '#abb2bf', hoverBg: '#61afef', hoverFg: '#282c34' }
12// Language label: One Dark's comment grey, quiet next to the chip.
13const LANG_FG = '#7f848e'
14// Header colors by level: One Dark blue for # and ##, cyan for ###, plain text below that.
15const HEADING_FG: Record<number, string> = { 1: '#61afef', 2: '#61afef', 3: '#56b6c2' }
16const HEADING_DEFAULT_FG = '#abb2bf'
17// **bold** in One Dark yellow so key points pop; bullets and list numbers in the header blue.
18const BOLD_FG = '#e5c07b'
19const BULLET_FG = '#61afef'
20const BULLETS = ['•', '◦', '▪']
21
22const bulletFor = (item: ListItem) => (item.ordered ? item.marker : (BULLETS[item.depth] ?? '•'))
23
24export const register: Register = on => {
25  // Your own prompts get a navy row; notifications and other senders keep the built-in look.
26  on('ui.render', { component: 'UserMessage' }, ($, e, next) => {
27    const kind = e.props.origin.kind
28    if (kind !== 'composer' && kind !== 'bridge') return next(e)
29
30    const { Box, Text } = $.ui.resolve(e)
31    return (
32      <Box flexDirection="row" backgroundColor={PROMPT_BG} paddingRight={1}>
33        <Text color={BULLET_FG} bold>
34          {'❯ '}
35        </Text>
36        <Box flexShrink={1}>
37          <Text>{e.props.text}</Text>
38        </Box>
39      </Box>
40    )
41  })
42
43  on('ui.render', { component: 'AssistantMessage' }, ($, e, next) => {
44    if (e.props.isSummary) return next(e)
45
46    const segments = splitBlocks(splitFences(e.props.text))
47    const hasStyling = segments.some(
48      s => s.kind === 'code' || s.kind === 'heading' || (s.kind === 'prose' && s.parts.some(p => p.kind === 'list' || p.text.includes('**'))),
49    )
50    if (!hasStyling) return next(e)
51
52    const { Box, Button, Code, Link, Markdown, Text } = $.ui.resolve(e)
53
54    const copy = async (source: string, surface: typeof e.surface) => {
55      const result = await $.ui.copy({ text: source, surface })
56      $.ui.toast(result.isCopied ? 'Copied code to clipboard' : `Copy failed: ${result.reason}`)
57    }
58
59    const inline = (parts: Inline[]): (string | JSX.Element)[] =>
60      parts.map(p => {
61        if (p.kind === 'text') return p.text
62        if (p.kind === 'bold')
63          return (
64            <Text bold color={BOLD_FG}>
65              {inline(p.children)}
66            </Text>
67          )
68        if (p.kind === 'italic') return <Text italic>{inline(p.children)}</Text>
69        if (p.kind === 'code') return <Text backgroundColor={CODE_BG}>{p.text}</Text>
70        return <Link href={p.href}>{p.text}</Link>
71      })
72
73    return (
74      <Box flexDirection="column" gap={1}>
75        {segments.map((s, i) => {
76          if (s.kind === 'text') return <Markdown text={s.text} />
77
78          if (s.kind === 'heading') {
79            const color = HEADING_FG[s.level] ?? HEADING_DEFAULT_FG
80            // # and ## get a colored bar on the left so sections stand out when scrolling.
81            return (
82              <Text color={color} bold underline={s.level === 1}>
83                {s.level <= 2 ? `▌ ${s.text}` : s.text}
84              </Text>
85            )
86          }
87
88          if (s.kind === 'prose') {
89            return (
90              <Box flexDirection="column">
91                {s.parts.map(part =>
92                  part.kind === 'paragraph' ? (
93                    <Text>{inline(parseInline(part.text))}</Text>
94                  ) : (
95                    <Box flexDirection="column">
96                      {part.items.map(item => (
97                        <Box flexDirection="row" marginLeft={item.depth * 2}>
98                          <Text color={BULLET_FG} bold>{`${bulletFor(item)} `}</Text>
99                          <Box flexShrink={1}>
100                            <Text>{inline(parseInline(item.text))}</Text>
101                          </Box>
102                        </Box>
103                      ))}
104                    </Box>
105                  ),
106                )}
107              </Box>
108            )
109          }
110
111          return (
112            <Box flexDirection="column" backgroundColor={CODE_BG} paddingX={1}>
113              <Box key={`chip-${i}`}>
114                <Box flexGrow={1}>
115                  {s.language && (
116                    <Text color={LANG_FG} italic>
117                      {s.language}
118                    </Text>
119                  )}
120                </Box>
121                <Button key={`copy-${i}`} plain onPress={press => copy(s.source, press.surface)}>
122                  <Text
123                    color={CHIP.fg}
124                    backgroundColor={CHIP.bg}
125                    hover={{ color: CHIP.hoverFg, backgroundColor: CHIP.hoverBg, bold: true }}
126                  >
127                    {' ⧉ copy '}
128                  </Text>
129                </Button>
130              </Box>
131              <Code source={s.source} language={s.language} />
132            </Box>
133          )
134        })}
135      </Box>
136    )
137  })
138}
139
hooks/parse.ts 146 lines
1// Splits a reply's markdown into the pieces reply-style draws itself (headers, paragraphs,
2// lists, code blocks) and the ones it leaves to the built-in renderer (tables, quotes, ...).
3
4export type Inline =
5  | { kind: 'text'; text: string }
6  | { kind: 'bold'; children: Inline[] }
7  | { kind: 'italic'; children: Inline[] }
8  | { kind: 'code'; text: string }
9  | { kind: 'link'; text: string; href: string }
10
11export type ListItem = { depth: number; marker: string; ordered: boolean; text: string }
12
13export type ProsePart = { kind: 'paragraph'; text: string } | { kind: 'list'; items: ListItem[] }
14
15export type Segment =
16  | { kind: 'text'; text: string }
17  | { kind: 'code'; language?: string; source: string }
18  | { kind: 'heading'; level: number; text: string }
19  | { kind: 'prose'; parts: ProsePart[] }
20
21const HEADING = /^ {0,3}(#{1,6})\s+(.*?)(?:\s+#+)?\s*$/
22const LIST_ITEM = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/
23// Lines that start a block the built-in renderer handles better: tables, quotes, rules, HTML.
24const BUILT_IN = /^\s*(\||>|<|(?:-{3,}|\*{3,}|_{3,})\s*$)/
25
26// Split markdown into prose and fenced code; an unclosed fence (mid-stream) runs to the end.
27export function splitFences(markdown: string): Segment[] {
28  const segments: Segment[] = []
29  let buffer: string[] = []
30  // Set while inside a code block: the fence that closes it and the block's language.
31  let open: { fence: string; language?: string } | undefined
32
33  const flush = () => {
34    const text = buffer.join('\n')
35    if (open) segments.push({ kind: 'code', language: open.language, source: text })
36    else if (text.trim()) segments.push({ kind: 'text', text })
37    buffer = []
38  }
39
40  for (const line of markdown.split('\n')) {
41    if (open) {
42      if (line.trim().startsWith(open.fence)) {
43        flush()
44        open = undefined
45      } else buffer.push(line)
46      continue
47    }
48
49    const match = line.match(/^\s*(`{3,}|~{3,})\s*([\w.+#-]*)/)
50    if (!match) {
51      buffer.push(line)
52      continue
53    }
54
55    flush()
56    open = { fence: match[1] ?? '```', language: match[2] || undefined }
57  }
58
59  flush()
60  return segments
61}
62
63// Break prose into headers and blank-line-separated blocks; each block is drawn as paragraphs
64// and lists, or handed back as plain text when it holds something like a table.
65export function splitBlocks(segments: Segment[]): Segment[] {
66  return segments.flatMap(segment => {
67    if (segment.kind !== 'text') return [segment]
68
69    const out: Segment[] = []
70    let block: string[] = []
71    const flush = () => {
72      if (block.length) out.push(toBlock(block))
73      block = []
74    }
75
76    for (const line of segment.text.split('\n')) {
77      const heading = line.match(HEADING)
78      if (heading) {
79        flush()
80        const text = (heading[2] ?? '').replace(/\*\*|__|`/g, '')
81        out.push({ kind: 'heading', level: heading[1]?.length ?? 1, text })
82      } else if (!line.trim()) flush()
83      else block.push(line)
84    }
85
86    flush()
87    return out
88  })
89}
90
91function toBlock(lines: string[]): Segment {
92  const first = lines[0] ?? ''
93  const isIndentedCode = /^ {4}/.test(first) && !LIST_ITEM.test(first)
94  if (isIndentedCode || lines.some(line => BUILT_IN.test(line))) return { kind: 'text', text: lines.join('\n') }
95
96  const parts: ProsePart[] = []
97  for (const line of lines) {
98    const last = parts[parts.length - 1]
99    const item = line.match(LIST_ITEM)
100
101    if (item) {
102      const marker = item[2] ?? '-'
103      const entry = {
104        depth: Math.min(Math.floor((item[1]?.length ?? 0) / 2), 2),
105        marker,
106        ordered: /\d/.test(marker),
107        text: item[3] ?? '',
108      }
109      if (last?.kind === 'list') last.items.push(entry)
110      else parts.push({ kind: 'list', items: [entry] })
111    } else if (last?.kind === 'list' && /^\s/.test(line)) {
112      // An indented line under a list item continues that item.
113      const lastItem = last.items[last.items.length - 1]
114      if (lastItem) lastItem.text += ` ${line.trim()}`
115    } else if (last?.kind === 'paragraph') last.text += ` ${line.trim()}`
116    else parts.push({ kind: 'paragraph', text: line.trim() })
117  }
118
119  return { kind: 'prose', parts }
120}
121
122// **bold**, *italic*, `code` and [links](url); anything else, including an unclosed mark
123// while the reply is still streaming, stays as plain text.
124const INLINE = /\*\*(.+?)\*\*|`([^`]+)`|\[([^\]]+)\]\(([^)\s]+)\)|(?<![\w*])\*(?![\s*])([^*]+?)(?<!\s)\*(?![\w*])/g
125
126export function parseInline(text: string): Inline[] {
127  const out: Inline[] = []
128  let index = 0
129
130  for (const match of text.matchAll(INLINE)) {
131    const start = match.index ?? 0
132    if (start > index) out.push({ kind: 'text', text: text.slice(index, start) })
133
134    const [whole, bold, code, linkText, href, italic] = match
135    if (bold !== undefined) out.push({ kind: 'bold', children: parseInline(bold) })
136    else if (code !== undefined) out.push({ kind: 'code', text: code })
137    else if (linkText !== undefined && href !== undefined) out.push({ kind: 'link', text: linkText, href })
138    else if (italic !== undefined) out.push({ kind: 'italic', children: parseInline(italic) })
139
140    index = start + whole.length
141  }
142
143  if (index < text.length) out.push({ kind: 'text', text: text.slice(index) })
144  return out
145}
146