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

Personal dotfiles + provisioning scripts for a brand new machine:
setup.sh — Debian/Ubuntu (apt)setup.mac.sh — macOS (Homebrew)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.
setup.sh installs.zshrc, .vimrc, .tmux.conf, starship.toml, ghostty.config, herdr_config.toml, nvim/, yazi/*)git config --global core.editor nvimlazy.nvim) and yazi plugins (ya pkg install)setup.mac.sh installsSame set, via Homebrew, minus what macOS already provides:
zsh-autosuggestions (the brew formula, which is what .zshrc.mac sources)~/.cargo/env exists for .zshrc.mac)$(brew --prefix)/bin).zshrc.mac as ~/.zshrcgit config --global core.editor nvimDifferences from the Linux script, all deliberate:
pbcopy; compilers come from the Command Line Tools.bat→batcat / fd→fdfind renames, so no ~/.local/bin shims.fastfetch takes its place..zshrc.mac's hardcoded /usr/local paths or /Users/jackyyeh home directory don't match this machine (they don't on Apple Silicon).A few things are deliberately not automated because they're either interactive, machine/version-specific, or risky to guess at:
chsh didn't run automatically (e.g. non-interactive session), run: ``bash chsh -s $(command -v zsh) `` then log out and back in.setup.sh adds you to the docker group, but that only takes effect after you log out/in (or run newgrp docker).setup.mac.sh installs the cask, but you must launch it once from /Applications before the docker CLI exists./usr/local/go, matching the PATH entry already in .zshrc.linux).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.setup.sh only symlinks ghostty.config into ~/.config/ghostty/config. On macOS setup.mac.sh installs the cask for you.bash ssh-keygen -t ed25519 -C "your_email@example.com" ``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.hooks/register.tsx 139 lines1import 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}
139hooks/parse.ts 146 lines1// 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