SLOPSHOPPER

latex-render

Draws display LaTeX ($$...$$ or \[...\]) in assistant replies as typeset images (tectonic + kitty graphics)

newrowspromptprocess
v0.1.0no licenseupdated 2026-10-05chang-07/cc-latex/latex-render
A shopper browsing a rack in a slop shop
README

cc-latex

Typeset LaTeX inline in Claude Code replies.

A Claude Code mod (function-hooks plugin) that redraws assistant messages so every display-math block ($$...$$ or \[...\]) is compiled with tectonic, rasterised with pdftocairo, and drawn in the transcript as an image over the kitty graphics protocol. Inline $x^2$ stays as text.

Requirements

  • macOS (the mod re-saves each image with sips)
  • Claude Code 2.1.287+ with mods (function hooks) available
  • A terminal that supports kitty graphics: Ghostty or kitty. (WezTerm and iTerm2 may work; untested.)
  • Homebrew

Install

brew tap chang-07/cc-latex https://github.com/chang-07/cc-latex
brew trust chang-07/cc-latex    # Homebrew 6 and later; skip if `brew trust` doesn't exist
brew install cc-latex
cc-latex install

Then start a new Claude Code session and ask for some math ("derive 2x sin x"). The formulas appear typeset. The mod adds a line to the system prompt telling Claude to write standalone equations as $$...$$, so you don't have to ask for LaTeX.

brew install brings tectonic and poppler with it. cc-latex install then does two things:

  • has tectonic fetch its TeX bundle and the usual math fonts (20 s to a minute, one time), so formulas appear at once in a session;
  • adds the mod to CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json, so every session loads it.

It keeps your other settings and is safe to run again. cc-latex remove takes the mod out of the settings file; run it before brew uninstall cc-latex.

From a checkout

To work on the mod, or without the tap:

git clone https://github.com/chang-07/cc-latex ~/code/cc-latex
~/code/cc-latex/scripts/install.sh

install.sh is what cc-latex install runs; from a checkout it also installs tectonic and poppler with Homebrew if they're missing, and install.sh --remove undoes it.

To try it for one session without installing:

claude --plugin-dir ~/code/cc-latex/latex-render

A formula that needs a font tectonic hasn't fetched yet (an unusual symbol) shows as dim $$ ... $$ text for a few seconds the first time, while it downloads.

If you only see the LaTeX source, dimmed, where a formula should be, Claude Code's terminal probe decided images aren't supported. Skip the probe with:

CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1 claude

Using tmux?

tmux drops the kitty graphics protocol, so by itself the mod shows formulas as latex code blocks when it detects tmux. scripts/cc-tmux-bridge.py fixes that. It runs Claude Code in a pty and wraps each graphics command in tmux's passthrough envelope so it reaches the real terminal. Claude Code already uses kitty's Unicode-placeholder mode under tmux, so no other change is needed.

tmux set -g allow-passthrough all       # also put this in ~/.tmux.conf
cc-tmux-bridge claude                   # from a checkout: python3 ~/code/cc-latex/scripts/cc-tmux-bridge.py claude

The bridge also answers Claude Code's image probe (no env flag needed), measures the terminal's cell size so formulas are sized exactly, redraws tmux once output goes quiet after an image, and keeps the terminal's image store healthy: Claude Code deletes and re-uploads every image on each redraw, which the bridge collapses to one upload per formula, and it evicts the oldest images past CC_TMUX_BRIDGE_MAX_IMAGES (default 24) so the terminal never hits its storage limit and starts refusing uploads silently. If formulas ever stop appearing, a stuck store is the first suspect: send Ghostty printf '\033Ptmux;\033\033_Ga=d,d=A\033\033\\\033\\' > $(tmux display -p '#{pane_tty}') to clear it.

To make it automatic, put a claude wrapper earlier in PATH than the real launcher that runs the bridge when $TMUX is set:

mkdir -p ~/.local/bridge-bin && cat > ~/.local/bridge-bin/claude <<'EOF'
#!/bin/sh
REAL="$HOME/.local/bin/claude"
if [ -n "$TMUX" ] && [ -z "$CC_TMUX_BRIDGE" ] && [ -t 1 ] && command -v cc-tmux-bridge >/dev/null; then
  exec cc-tmux-bridge "$REAL" "$@"
fi
exec "$REAL" "$@"
EOF
chmod +x ~/.local/bridge-bin/claude
echo 'export PATH="$HOME/.local/bridge-bin:$PATH"' >> ~/.zshrc

scripts/tmux-placeholder-test.py draws a PNG in the current tmux pane with placeholders, handy to check that your terminal + tmux combination can show images at all.

How it works

hooks/register.tsx hooks ui.render for the AssistantMessage site. It splits the message text around display-math blocks (skipping code fences and inline code), returns the surrounding markdown as Markdown elements and each formula as an Image. Rendering runs in the background: a formula not yet typeset shows as dim $$ ... $$ text, and when its PNG is ready the hook calls $.ui.invalidate('ui.render') so the message redraws with the picture.

Rendered PNGs are cached in ~/.cache/claude-latex/ by a hash of the LaTeX source, white on transparent (dark terminals).

Tuning

Constants at the top of hooks/register.tsx:

ConstantDefaultMeaning
PT_PER_ROW8Points of typeset height per terminal row. Lower = bigger.
CELL_ASPECT2.1Cell height / width of your terminal font, used when the bridge hasn't measured it.
TEXT_COLORwhiteFormula colour; use black on a light theme.
DPI400Rasterisation resolution; the picture is scaled to its cell box, and the terminal holds every formula decoded, so higher costs memory.

Development

claude plugin validate latex-render
claude plugin test latex-render
Source 1 files
hooks/register.tsx 271 lines
1import type { Register, EngineInterface } from 'claude-code'
2
3// Display math in an assistant reply ($$...$$ or \[...\]) is typeset with
4// tectonic, rasterised with pdftocairo, and drawn inline as an Image over the
5// kitty graphics protocol (Ghostty, kitty). Inline $...$ is left as text.
6//
7// Inside tmux the engine's image upload does not reach the terminal unless
8// Claude Code runs under scripts/cc-tmux-bridge.py (which sets
9// CC_TMUX_BRIDGE=1); without the bridge, formulas are shown as LaTeX source.
10
11const DPI = 400 // rasterisation; above the screen's pixels per point, but every formula is held decoded by the terminal
12const PT_PER_ROW = 8 // points of typeset height per terminal row; lower = bigger
13const CELL_ASPECT = 2.1 // cell height / cell width, used when no measured size is available
14const TEXT_COLOR = 'white' // the terminal is dark; change for a light theme
15
16type Piece = { kind: 'md'; text: string } | { kind: 'tex'; src: string }
17
18// Fenced blocks and inline code spans are skipped: a `$$` quoted in prose
19// must not pair with a real delimiter.
20const FENCE = /(```[\s\S]*?```|~~~[\s\S]*?~~~|`[^`\n]*`)/
21const DISPLAY = /\$\$([\s\S]+?)\$\$|\\\[([\s\S]+?)\\\]/g
22
23function split(text: string): Piece[] {
24  const out: Piece[] = []
25  for (const chunk of text.split(FENCE)) {
26    if (!chunk) continue
27    if (chunk.startsWith('```') || chunk.startsWith('~~~') || chunk.startsWith('`')) {
28      out.push({ kind: 'md', text: chunk })
29      continue
30    }
31    let last = 0
32    for (const m of chunk.matchAll(DISPLAY)) {
33      const src = (m[1] ?? m[2] ?? '').trim()
34      if (m.index! > last) out.push({ kind: 'md', text: chunk.slice(last, m.index) })
35      out.push({ kind: 'tex', src })
36      last = m.index! + m[0].length
37    }
38    if (last < chunk.length) out.push({ kind: 'md', text: chunk.slice(last) })
39  }
40  return out
41}
42
43const hashes = new Map<string, Promise<string>>() // formula source -> cache key
44function sha(text: string): Promise<string> {
45  let p = hashes.get(text)
46  if (!p) {
47    p = crypto.subtle.digest('SHA-256', new TextEncoder().encode(text)).then(buf =>
48      [...new Uint8Array(buf)].map(b => b.toString(16).padStart(2, '0')).join('').slice(0, 16),
49    )
50    hashes.set(text, p)
51  }
52  return p
53}
54
55function pngSize(base64: string): { width: number; height: number } | null {
56  const b = Uint8Array.fromBase64(base64)
57  if (b.length < 24 || b[12] !== 0x49 || b[13] !== 0x48 || b[14] !== 0x44 || b[15] !== 0x52) return null
58  const v = new DataView(b.buffer, b.byteOffset)
59  return { width: v.getUint32(16), height: v.getUint32(20) }
60}
61
62type Rendered = { png: string; width: number; height: number }
63const done = new Map<string, Rendered | null>() // settled renders, by key
64const pending = new Map<string, string>() // key -> source, waiting for the next batch
65let batchTimer: ReturnType<typeof setTimeout> | null = null
66let batchRunning = false
67let home: Promise<string> | null = null
68
69function cacheDir($: EngineInterface): Promise<string> {
70  if (!home) home = $.env.get('HOME').then(h => `${h ?? '/tmp'}/.cache/claude-latex`)
71  return home
72}
73
74// Answers at once: the settled picture, or `undefined` while it renders. The
75// formulas of one redraw are compiled together (one tectonic run), and the
76// batch settling asks the engine to draw this plugin's sites again, once.
77function render($: EngineInterface, key: string, src: string): Rendered | null | undefined {
78  if (done.has(key)) return done.get(key)
79  if (!pending.has(key)) pending.set(key, src)
80  // Every formula of a redraw is registered within the same hook call, so the
81  // window only has to outlast the current tick.
82  if (!batchTimer && !batchRunning) batchTimer = setTimeout(() => void runBatch($), 10)
83  return undefined
84}
85
86async function runBatch($: EngineInterface): Promise<void> {
87  batchTimer = null
88  if (batchRunning || pending.size === 0) return
89  batchRunning = true
90  const items = [...pending].map(([key, src]) => ({ key, src }))
91  pending.clear()
92  try {
93    const dir = await cacheDir($)
94    const fresh: typeof items = []
95    for (const it of items) (await $.fs.exists(`${dir}/${it.key}.png`)) ? void 0 : fresh.push(it)
96    if (fresh.length > 0) {
97      try {
98        await compile($, dir, fresh)
99      } catch (err) {
100        // One bad formula fails the whole compile: retry them one at a time.
101        $.ui.log(`latex-render: batch failed (${String(err).slice(0, 200)}); retrying singly`, { to: 'debug' })
102        for (const it of fresh) {
103          await compile($, dir, [it]).catch(e => $.ui.log(`latex-render: ${it.key}: ${String(e).slice(0, 200)}`, { to: 'debug' }))
104        }
105      }
106    }
107    for (const it of items) done.set(it.key, await load($, `${dir}/${it.key}.png`))
108  } finally {
109    batchRunning = false
110    $.ui.invalidate('ui.render')
111    if (pending.size > 0) batchTimer = setTimeout(() => void runBatch($), 0)
112  }
113}
114
115// One tectonic run for several formulas: each display becomes its own tightly
116// cropped page (the preview package), rasterised to <key>.png.
117async function compile($: EngineInterface, dir: string, items: { key: string; src: string }[]): Promise<void> {
118  const id = items.length === 1 ? items[0]!.key : `batch-${items.map(i => i.key.slice(0, 4)).join('')}`
119  const tex = `${dir}/${id}.tex`
120  await $.fs.write(
121    tex,
122    [
123      '\\documentclass{article}',
124      '\\usepackage{amsmath,amssymb,amsfonts,xcolor}',
125      // Each formula is its own tight preview box around displaystyle math. (The
126      // package's displaymath extraction keeps the display's whole line, which
127      // leaves the formula centred in a canvas of transparent space.)
128      '\\usepackage[active,tightpage]{preview}',
129      '\\setlength\\PreviewBorder{3pt}',
130      '\\begin{document}',
131      ...items.map(i => `\\begin{preview}\\color{${TEXT_COLOR}}\\(\\displaystyle ${i.src} \\)\\end{preview}`),
132      '\\end{document}',
133      '',
134    ].join('\n'),
135  )
136  // Even with --only-cached, tectonic contacts the bundle server on every run
137  // and waits on it (seconds on a slow link, with almost no CPU used). Pointing
138  // its proxy at a closed local port makes that attempt fail at once, so the
139  // compile runs from the cache in well under a second. A package not cached
140  // yet makes this run fail, and the fallback compiles with the network open.
141  const offline = { https_proxy: 'http://127.0.0.1:9', http_proxy: 'http://127.0.0.1:9', HTTPS_PROXY: 'http://127.0.0.1:9', HTTP_PROXY: 'http://127.0.0.1:9' }
142  // tectonic reruns TeX when the .aux changes, and a first pass always writes
143  // one. Writing the two lines it would produce skips that second pass.
144  await $.fs.write(`${dir}/${id}.aux`, `\\relax \n\\gdef \\@abspage@last{${items.length}}\n`)
145  let tect = await $.process.run(['tectonic', '-o', dir, '--chatter', 'minimal', '--only-cached', tex], { env: offline, timeoutMs: 60000 })
146  if (tect.exitCode !== 0) {
147    tect = await $.process.run(['tectonic', '-o', dir, '--chatter', 'minimal', tex], { timeoutMs: 120000 })
148  }
149  if (tect.exitCode !== 0) throw new Error(`tectonic: ${tect.stderr.slice(0, 300)}`)
150  // One pdftocairo run for every page (it names them <prefix>-<n>.png, n
151  // zero-padded to the digits of the page count), then each page is moved to
152  // its formula's cache name; one process instead of one per formula.
153  const pdf = `${dir}/${id}.pdf`
154  const prefix = `${dir}/${id}`
155  const width = String(items.length).length
156  // Ghostty draws cairo's PNG byte stream at a fraction of its size (the same
157  // pixels re-saved by any other encoder draw right), so each page is re-saved
158  // by sips on the way to its cache name; alpha survives.
159  const moves = items
160    // A page wider than 4096 px is downsampled first (never enlarged): the
161    // engine refuses a larger Image, and with it the whole message's tree.
162    .map((it, n) => {
163      const page = `${prefix}-${String(n + 1).padStart(width, '0')}.png`
164      return `w=$(sips -g pixelWidth "${page}" | awk '/pixelWidth/{print $2}'); if [ "$w" -gt 4096 ]; then sips --resampleWidth 4096 "${page}" >/dev/null; fi; sips -s format png "${page}" --out "${dir}/${it.key}.png" >/dev/null`
165    })
166    .join(' && ')
167  const cairo = await $.process.run(
168    ['sh', '-c', `pdftocairo -png -transp -r ${DPI} "${pdf}" "${prefix}" && ${moves} && rm -f "${prefix}"-*.png`],
169    { timeoutMs: 30000 },
170  )
171  if (cairo.exitCode !== 0) throw new Error(`pdftocairo: ${cairo.stderr.slice(0, 300)}`)
172}
173
174async function load($: EngineInterface, png: string): Promise<Rendered | null> {
175  try {
176    const { base64 } = await $.fs.read(png, { as: 'bytes' })
177    const size = pngSize(base64)
178    return size ? { png: base64, ...size } : null
179  } catch {
180    return null
181  }
182}
183
184// The terminal's cell size in pixels, as cc-tmux-bridge measures it and leaves
185// in ~/.cache/cc-tmux-bridge.cellsize ("<width> <height>"); CELL_ASPECT else.
186let cellAspect: Promise<number> | null = null
187function measuredCellAspect($: EngineInterface): Promise<number> {
188  if (!cellAspect) {
189    cellAspect = (async () => {
190      try {
191        const home = (await $.env.get('HOME')) ?? ''
192        const [w, h] = (await $.fs.read(`${home}/.cache/cc-tmux-bridge.cellsize`)).trim().split(/\s+/).map(Number)
193        if (w! > 0 && h! > 0) return h! / w!
194      } catch {}
195      return CELL_ASPECT
196    })()
197  }
198  return cellAspect
199}
200
201function cells(r: Rendered, maxColumns: number, aspectRatio: number): { columns: number; rows: number } {
202  const heightPt = (r.height * 72) / DPI
203  const aspect = (r.width / r.height) * aspectRatio // columns per row
204  let rows = Math.max(1, Math.round(heightPt / PT_PER_ROW))
205  // Err a little wide: the bridge trims the box to the picture's exact shape
206  // (it can only shrink it), and a box too narrow would shrink the picture.
207  let columns = Math.max(1, Math.ceil(rows * aspect * 1.04))
208  if (columns > maxColumns) {
209    columns = maxColumns
210    rows = Math.max(1, Math.round(columns / aspect))
211  }
212  return { columns: Math.min(columns, 255), rows: Math.min(rows, 255) }
213}
214
215// The model avoids LaTeX in a terminal unless told it will be drawn, and then
216// this mod has nothing to render. One system-prompt section says how to write
217// math here.
218const GUIDANCE =
219  'This terminal typesets display math. Write any standalone equation or formula as display LaTeX ' +
220  'between `$$` and `$$` on its own lines (amsmath and amssymb are available); it is drawn as a typeset image. ' +
221  'Inline `$...$` is not rendered, so keep short inline expressions as plain Unicode text (x², ∑, √, π). ' +
222  'Never put display math inside a code block or backticks, where it stays as source.'
223
224export const register: Register = on => {
225  on('prompt.compose', async ($, e, next) => {
226    const composed = await next(e)
227    // Only the terminal draws the pictures.
228    if (!e.surfaces.includes('terminal')) return composed
229    return { sections: [...composed.sections, { id: 'latex-render:math', text: GUIDANCE, scope: 'session' }] }
230  })
231
232  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
233    if (e.surface !== 'terminal') return next(e)
234    const pieces = split(e.props.text)
235    if (!pieces.some(p => p.kind === 'tex')) return next(e)
236
237    const { Box, Text, Markdown, Image } = $.ui.resolve(e)
238    const maxColumns = Math.min(255, Math.max(10, (e.viewport?.columns ?? 80) - 4))
239    const bridged = await $.env.get('CC_TMUX_BRIDGE').then(Boolean, () => false)
240    const inTmux = !bridged && (await $.env.get('TMUX').then(Boolean, () => false))
241    const aspectRatio = await measuredCellAspect($)
242
243    const keys = await Promise.all(pieces.map(p => (p.kind === 'tex' ? sha(p.src) : '')))
244    const nodes = pieces.map((p, i) => {
245      if (p.kind === 'md') {
246        const text = p.text.replace(/^\n+|\n+$/g, '')
247        return text ? <Markdown text={text.slice(0, 10000)} /> : null
248      }
249      if (inTmux) return <Markdown text={'```latex\n' + p.src + '\n```'} />
250      const r = render($, keys[i]!, p.src)
251      if (r === undefined) return <Markdown text={'$$ ' + p.src + ' $$'} dimColor />
252      if (r === null) return <Markdown text={'```latex\n' + p.src + '\n```'} />
253      const { columns, rows } = cells(r, maxColumns, aspectRatio)
254      return (
255        <Box marginLeft={2} marginTop={1} marginBottom={1}>
256          <Image source={{ png: r.png }} columns={columns} rows={rows} alt={p.src} />
257        </Box>
258      )
259    })
260
261    return (
262      <Box flexDirection="row">
263        <Text>{e.props.isFirstOfReply ? '● ' : '  '}</Text>
264        <Box flexDirection="column" flexGrow={1}>
265          {nodes}
266        </Box>
267      </Box>
268    )
269  })
270}
271