Typesets the LaTeX math in Claude's replies as images in the terminal, through the kitty graphics protocol

A Claude Code mod that typesets the LaTeX math in Claude's replies. Display math becomes an image in the reply. Inline math becomes a small image inside the line of text.

Inside Claude Code, run:
/plugin marketplace add atomashevic/claude-latex-math
/plugin install latex-math
/reload-plugins
From a shell, the same steps are:
claude plugin marketplace add atomashevic/claude-latex-math
claude plugin install latex-math@claude-latex-math
Then run /reload-plugins inside a session, or start a new session.
latex, dvipng and kpsewhich from TeX Live, with the LaTeX packages amsmath, amssymb, mathtools, bm and preview.magick) or ImageMagick 6 (convert and identify).bash and awk.When a tool or a package is missing, the mod shows one notice at the start of the session and writes math as Unicode text.
Install the tools with one of these commands:
| System | Command | Tested |
|---|---|---|
| Arch Linux | sudo pacman -S texlive-bin texlive-basic texlive-latex texlive-latexrecommended texlive-latexextra imagemagick | Yes, with Ghostty 1.3.1 |
| Debian, Ubuntu | sudo apt install texlive-latex-recommended dvipng preview-latex-style imagemagick | The renderer, on Ubuntu 24.04 |
| macOS | brew install --cask basictex, then sudo tlmgr install dvipng preview mathtools and brew install imagemagick | No |
Windows is not supported, because the renderer is a bash script.
These prompts show what the mod does:
Explain the attention mechanism in transformers, with the formulas. The reply has display formulas for scaled dot-product attention and inline symbols such as $d_k$ in the text.Derive the least-squares estimator in matrix form, step by step. The reply has a chain of display formulas, and an align environment if Claude uses one.Write Maxwell's equations in differential form and explain each term. The reply has four display formulas and inline vector operators in the explanations.Change the options in /config, where each option is one row under the plugin's name. From a shell, claude plugin configure latex-math@claude-latex-math shows the options, and echo '{"images":"off"}' | claude plugin configure latex-math@claude-latex-math --values-stdin sets one.
| Option | Values | Default | What it does |
|---|---|---|---|
images | auto, on, off | auto | auto draws images in kitty and Ghostty and writes Unicode text in other terminals. on draws images in a terminal that the mod does not recognise, such as kitty over SSH with TERM set to another value. off always writes Unicode text. |
inline | image, unicode | image | How inline math is drawn when images are on. |
promptSection | true, false | true | Whether the system prompt tells Claude that math is typeset. |
scale | 0.5 to 2 | 1 | The size of display math, as a multiple of the default size. |
cacheSizeMB | 1 to 10000 | 100 | The largest size of the image cache, in MB. |
$$ ... $$, \[ ... \], and the amsmath environments equation, align, gather, multline, alignat, flalign and eqnarray become an image at the same position in the reply. The image takes as many rows as the formula needs.$ ... $ and \( ... \) in a paragraph or a list item become an image one text row tall, on the baseline of the text. The mod scales a taller formula down to fit the row.$\beta_0 \leq x^2$ becomes β₀ ≤ x².text from the theme file. With a built-in theme, it uses the foreground colour of the terminal.$5 and $10, code spans and code fences are not math.When the mod writes math as text, inline and display math become Unicode. A display formula that Unicode cannot hold, such as a matrix, becomes a LaTeX code block. A reply that needs more than 60 different formulas typeset is always written as text, so that one long derivation does not start 60 LaTeX runs.
When images are on and promptSection is true, the mod adds this section to the system prompt:
# Math rendering
This terminal typesets LaTeX math as images. Display math (`$$ ... $$` on its own lines, or an amsmath environment such as `\begin{align} ... \end{align}`) is drawn in place at full size. Inline math (`$...$`) in a paragraph or a list item is drawn inside the line, one text row tall, so keep it to expressions that fit a line and put tall formulas (stacked fractions, matrices, sums with limits above and below) in display math. In a table, a heading or a quote, inline math is written as Unicode text instead. Use only the commands of LaTeX and amsmath, and define no macros: a formula with any other command is shown as source.
With inline set to unicode, the two sentences about inline math are replaced by this one:
Inline math (`$...$`) is written as Unicode text, so keep it to short expressions (symbols, subscripts, superscripts, simple fractions) and put larger formulas in display math.
The mod adds nothing to the system prompt when it writes math as text, in Claude Desktop, or in the VS Code chat panel.
bin/render.sh --check to find missing tools. Then it deletes the least recently used images until the cache is under cacheSizeMB.bin/render.sh runs latex and dvipng on each formula. It pads the PNG to whole terminal cells and writes it to ~/.cache/claude-latex-math/. The file name is a hash of the formula, the colour and the scale.Image element of Claude Code. The terminal reads the file and shows it through the kitty graphics protocol. Over SSH, the terminal cannot read the file, so the mod sends the PNG bytes instead.On the development machine, a new formula takes about 0.2 seconds and a cached formula takes about 5 milliseconds.
Formulas show as Unicode text in kitty or Ghostty. Claude Code draws no images inside tmux or screen, or in a background session, so the mod writes Unicode text there. To make Claude Code draw images anyway, set CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1 in the env block of ~/.claude/settings.json and start a new session. Whether the images then show depends on the terminal. Outside tmux and screen, the mod can also miss a terminal whose TERM and TERM_PROGRAM were changed. Set images to on for that terminal.
A notice says that a tool was not found. Install the tools that the notice names, as the table in Requirements shows, and start a new session. To see what is missing, run the check yourself, with the installed version in the path:
bash ~/.claude/plugins/cache/claude-latex-math/latex-math/0.3.0/bin/render.sh --check
The command prints an empty line and exits with 0 when every tool and package is present.
A formula shows its source and a line that starts with not rendered:. The line gives the reason. Either LaTeX rejected the formula, and the line has the TeX error, or the formula has a command that is not on the list of math commands. Ask Claude to write the formula again with standard commands. If a standard math command is missing from the list, open an issue.
Formulas show as dim LaTeX source. The mod chose images, and the terminal cannot draw them. Set images to off to get Unicode text instead.
Formulas have the wrong colour. The mod reads the colour when it draws a reply. After you change the theme, new replies get the new colour.
The cache uses too much disk space. Lower cacheSizeMB, or delete ~/.cache/claude-latex-math. The mod renders each formula again when it needs it.
Turn the mod off. Disable latex-math in the Installed tab of /plugin.
text.BASELINE in bin/render.sh.\newcommand or \def, is not rendered.The mod runs only on your machine and sends no data anywhere. PRIVACY.md lists what it reads, what it writes and how long the files stay.
The mod makes no network requests and downloads nothing. Everything in this section stays on your computer.
The mod has three hooks.
ui.render on assistant messages. It changes how a reply that has math is drawn in the terminal: each formula becomes an image or Unicode text. It does not change the message itself. The transcript and the conversation that Claude sees keep the LaTeX source.prompt.compose. It adds one section to the end of the system prompt, quoted in What the mod tells Claude, when images are on and promptSection is true. It changes and removes no other section.session.start. It changes nothing in the session. In a terminal session it decides how to draw math, shows the notice about missing tools, and trims the image cache.The mod does not change your prompts, tool calls, permissions or settings.
The mod starts four commands with $.process.run. <plugin> is the plugin's folder. <stem> is the image's file name without .png: a hash of the formula, the colour and the scale.
| Command | When | Why |
|---|---|---|
bash <plugin>/bin/render.sh <cache folder> <stem> <colour> <display or inline> <scale> | Once for each new formula | Typesets the formula. The script runs latex, dvipng and ImageMagick (magick, or convert and identify) in a temporary folder, and deletes the folder when it ends. |
bash <plugin>/bin/render.sh --check | Once when a terminal session starts | Lists missing tools and LaTeX packages. The script runs command -v and kpsewhich. |
rm -f -- <cache files> | Once when a terminal session starts, if the cache is over cacheSizeMB | Deletes the least recently used images. It deletes only files in the cache folder whose names the renderer made. |
ghostty +show-config | In Ghostty, at most once every 5 seconds while it draws replies | Reads the terminal's foreground colour. |
The arguments that vary are the ones in angle brackets: a folder path, a hash, a colour of six hex digits, a mode, a number, and paths of files in the cache folder. The mod builds no command from a formula or from any other text of a reply. A formula reaches the renderer only on its standard input, as the body of a LaTeX document.
The renderer is a shell script because one formula takes latex, dvipng and ImageMagick in a temporary folder, and the mods API has no call that deletes a file. Every command name in bin/render.sh is written in the script.
A formula comes from the model, and LaTeX is a programming language. The mod limits what a formula can do in four ways.
hooks/commands.ts. The list has the symbols and the structural commands of LaTeX, amsmath, amssymb, mathtools and bm. It has no command that reads a file, defines a macro, or changes how LaTeX reads its input. A formula with any other command shows as its source.latex runs with shell escape off (-no-shell-escape), and dvipng runs with Ghostscript off (--nogs).latex runs with openout_any=p. The one listed command that writes is \label, which adds a line to LaTeX's own .aux file in that folder.latex and dvipng stop after 20 seconds, and a picture larger than 255 terminal cells in either direction is refused.LaTeX itself does not limit which files a formula can read: TeX Live 2026 made its openin_any setting a no-op. The command list is what keeps a formula from reading a file. tools/commands.py probe tests the list on the installed LaTeX. It calls each listed command with a canary command name and a canary file in every argument position, and it fails when LaTeX runs the name, opens the file, or leaves its input rules changed. CI runs the probe on every pull request and on every push to main.
bin/render.sh on its standard input.Image element: as a file path, or as PNG bytes over SSH.$.ui.log. Claude does not see it.$.fs.read. From a custom theme file in ~/.claude/themes/, it takes the text colour, or the name of the base theme when the file sets no text colour, to choose the <colour> argument of the renderer. Over SSH, it reads each image file that the renderer wrote, and the bytes go to the terminal. No other file content goes to a program or leaves the mod.theme row of /config, with $.config.list. It does not read the rest of your settings.ghostty +show-config, from which it takes the foreground colour.HOME, XDG_CACHE_HOME and CLAUDE_CONFIG_DIR, to find folders. TERM, TERM_PROGRAM, KITTY_WINDOW_ID, TMUX, STY, CLAUDE_CODE_SESSION_KIND and CLAUDE_CODE_FORCE_TERMINAL_IMAGES, to learn whether the terminal draws images. SSH_CONNECTION and SSH_TTY, to learn whether the terminal is on another computer. None of these is a credential, and the mod sends none of them anywhere.The mod reads no token, API key or password, from the environment or from a file.
It writes images to ~/.cache/claude-latex-math/, or to $XDG_CACHE_HOME/claude-latex-math/ when that variable is set. Run claude plugin validate . in the repository to see each event that the mod hooks and each call that it makes.
To report a vulnerability, see SECURITY.md.
Open an issue at github.com/atomashevic/claude-latex-math/issues. Give your terminal, your system, the output of claude --version, and the formula that fails.
git clone https://github.com/atomashevic/claude-latex-math
cd claude-latex-math
# Load the mod for one session without an install
claude --plugin-dir .
# Check the mod and run its tests
claude plugin validate .
claude plugin test .
# Run the renderer on real formulas
tests/render.test.sh
# Probe the list of math commands on your LaTeX
python3 tools/commands.py probe
CI runs the same commands on every push and pull request, and runs shellcheck on the shell scripts.
tools/commands.py generate writes hooks/commands.ts, the list of math commands. To allow one more command, add it to the lists in that script, run generate, then run probe.
To render one formula from a shell:
printf '\\[ e^{i\\pi} + 1 = 0 \\]' | bin/render.sh /tmp/math euler d8d8d8 display
The script prints the size in cells and writes /tmp/math/euler.png.
To render the demo animation, run demo/make_demo.py, then demo/encode.sh. They need Python with Pillow and ffmpeg. The animation in this README is the 720 px GIF that demo/encode.sh writes, because the plugin directory accepts no file over 5 MiB.
demo/make_icon.py draws the plugin icon from one formula typeset by LaTeX.
CHANGELOG.md lists the changes in each version.
The method comes from claude-image-view by Jarrod Watts, which draws pasted images with the same Image element.
MIT. See LICENSE.
hooks/register.tsx 296 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { overflow } from './cache'
4import { blocks } from './flow'
5import { refusal } from './guard'
6import type { Block, Item, Token } from './flow'
7import { fit, hexColour, segments, stemOf } from './math'
8import type { Cells } from './math'
9import { readSettings } from './settings'
10import type { Settings } from './settings'
11import { decide } from './terminal'
12import type { Drawing, Terminal } from './terminal'
13import { asText, inline, inlineMath } from './unicode'
14
15type Mode = 'display' | 'inline'
16/** One piece of a reply, top to bottom: a block of its text, or a display formula between two. */
17type Piece = Block | { kind: 'display'; tex: string; source: string }
18/** `png` holds the picture's bytes, base64, when the terminal cannot read this machine's files. */
19type Formula = { status: 'ready'; path: string; cells: Cells; png?: string } | { status: 'failed'; reason: string }
20
21// The transcript indents a reply's text by two columns, after its bullet.
22const INDENT = 2
23// How long a colour read stands, so a theme change reaches the next reply and a redraw stays cheap.
24const COLOUR_MS = 5000
25// A reply that needs more LaTeX runs than this is drawn as Unicode text, so a long derivation starts no flood of them.
26const MAX_FORMULAS = 60
27
28function section(inlineMath: Settings['inline']) {
29 const inlineText =
30 inlineMath === 'image'
31 ? 'Inline math (`$...$`) in a paragraph or a list item is drawn inside the line, one text row tall, so keep it to expressions that fit a line and put tall formulas (stacked fractions, matrices, sums with limits above and below) in display math. In a table, a heading or a quote, inline math is written as Unicode text instead.'
32 : 'Inline math (`$...$`) is written as Unicode text, so keep it to short expressions (symbols, subscripts, superscripts, simple fractions) and put larger formulas in display math.'
33 return {
34 id: 'latex-math:rendering',
35 scope: 'session',
36 text: [
37 '# Math rendering',
38 `This terminal typesets LaTeX math as images. Display math (\`$$ ... $$\` on its own lines, or an amsmath environment such as \`\\begin{align} ... \\end{align}\`) is drawn in place at full size. ${inlineText} Use only the commands of LaTeX and amsmath, and define no macros: a formula with any other command is shown as source.`,
39 ].join('\n'),
40 } as const
41}
42
43let settings: Settings = readSettings({})
44let session: Promise<Drawing> | undefined
45let cache: Promise<string> | undefined
46let colour: { readAt: number; value: Promise<string> } | undefined
47const formulas = new Map<string, Promise<Formula>>()
48
49// The glyphs are drawn over a transparent background in the colour Claude Code gives a reply's
50// text: a custom theme's `text`, and under a built-in theme the terminal's own foreground.
51async function textColour($: EngineInterface): Promise<string> {
52 // The theme's row in /config, which is all the mod needs; the whole settings object can hold secrets.
53 const theme = (await $.config.list()).find(row => row.key === 'theme')?.value
54 let base = String(theme ?? 'auto')
55 if (base.startsWith('custom:')) {
56 const config = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${await $.env.get('HOME')}/.claude`
57 const custom = await $.fs.read(`${config}/themes/${base.slice('custom:'.length)}.json`).then(
58 text => JSON.parse(text) as { base?: string; overrides?: { text?: string } },
59 () => undefined,
60 )
61 const text = hexColour(custom?.overrides?.text)
62 if (text !== undefined) return text
63 base = custom?.base ?? 'dark'
64 }
65 if ((await $.env.get('TERM_PROGRAM')) === 'ghostty') {
66 const config = await $.process.run(['ghostty', '+show-config']).catch(() => undefined)
67 const foreground = hexColour(config?.stdout.match(/^foreground = (#[0-9a-fA-F]{6})$/m)?.[1])
68 if (foreground !== undefined) return foreground
69 }
70 return base.includes('light') ? '000000' : 'ffffff'
71}
72
73function currentColour($: EngineInterface): Promise<string> {
74 const now = Date.now()
75 if (colour === undefined || now - colour.readAt > COLOUR_MS) {
76 colour = { readAt: now, value: textColour($).catch(() => 'ffffff') }
77 }
78 return colour.value
79}
80
81async function cacheDir($: EngineInterface): Promise<string> {
82 const home = (await $.env.get('XDG_CACHE_HOME')) ?? `${await $.env.get('HOME')}/.cache`
83 return `${home}/claude-latex-math`
84}
85
86function listed(names: string): string {
87 const all = names.split(' ')
88 return all.length === 1 ? all.join('') : `${all.slice(0, -1).join(', ')} and ${all[all.length - 1]}`
89}
90
91// Deletes the least recently used pictures until the cache fits its limit.
92async function prune($: EngineInterface): Promise<void> {
93 const dir = await (cache ??= cacheDir($))
94 const doomed = overflow(await $.fs.list(dir), settings.cacheBytes)
95 for (let i = 0; i < doomed.length; i += 200) {
96 await $.process.run(['rm', '-f', '--', ...doomed.slice(i, i + 200).map(name => `${dir}/${name}`)])
97 }
98}
99
100async function start($: EngineInterface): Promise<Drawing> {
101 const env: Terminal = {
102 TERM: await $.env.get('TERM'),
103 TERM_PROGRAM: await $.env.get('TERM_PROGRAM'),
104 KITTY_WINDOW_ID: await $.env.get('KITTY_WINDOW_ID'),
105 TMUX: await $.env.get('TMUX'),
106 STY: await $.env.get('STY'),
107 SSH_CONNECTION: await $.env.get('SSH_CONNECTION'),
108 SSH_TTY: await $.env.get('SSH_TTY'),
109 CLAUDE_CODE_SESSION_KIND: await $.env.get('CLAUDE_CODE_SESSION_KIND'),
110 CLAUDE_CODE_FORCE_TERMINAL_IMAGES: await $.env.get('CLAUDE_CODE_FORCE_TERMINAL_IMAGES'),
111 }
112 const chosen = await decide(settings.images, env, async () => {
113 const ran = await $.process.run(['bash', `${$.plugin.root}/bin/render.sh`, '--check'])
114 return ran.exitCode === 0 ? '' : ran.stdout.trim() || 'a tool'
115 })
116 if (chosen.kind === 'text' && chosen.reason === 'missing') {
117 $.ui.log(
118 `latex-math: ${listed(chosen.missing)} not found, so math is shown as Unicode text. The plugin's README lists what to install, under Requirements.`,
119 )
120 }
121 await prune($).catch(() => undefined)
122 return chosen
123}
124
125// Decided once a session: on its start, or on the first draw where a test or a reload skipped the start.
126// A start that fails (an abandoned dispatch, a check that could not run) draws text once and is tried again.
127function drawing($: EngineInterface): Promise<Drawing> {
128 session ??= start($).catch((): Drawing => {
129 session = undefined
130 return { kind: 'text', reason: 'terminal' }
131 })
132 return session
133}
134
135async function render($: EngineInterface, tex: string, foreground: string, mode: Mode, bytes: boolean): Promise<Formula> {
136 const refused = refusal(tex)
137 if (refused !== undefined) return { status: 'failed', reason: refused }
138 const dir = await (cache ??= cacheDir($))
139 const scale = mode === 'display' ? settings.scale : 1
140 const stem = stemOf(scale === 1 ? foreground : `${foreground}@${scale}`, tex)
141 const ran = await $.process.run(
142 ['bash', `${$.plugin.root}/bin/render.sh`, dir, stem, foreground, mode, String(scale)],
143 { stdin: tex },
144 )
145 const [columns, rows] = ran.stdout.trim().split(' ').map(Number)
146 if (ran.exitCode !== 0 || !columns || !rows) {
147 return { status: 'failed', reason: ran.stderr.trim().slice(0, 200) || 'the renderer printed no size' }
148 }
149 const path = `${dir}/${stem}.png`
150 const png = bytes ? (await $.fs.read(path, { as: 'bytes' })).base64 : undefined
151 return { status: 'ready', path, cells: { columns, rows }, png }
152}
153
154async function formula($: EngineInterface, tex: string, foreground: string, mode: Mode, bytes: boolean): Promise<Formula> {
155 const id = `${foreground}\n${tex}`
156 const known = formulas.get(id)
157 if (known !== undefined) {
158 const result = await known
159 // Another session's cache trim, or the user, can delete a picture this session still draws.
160 if (result.status === 'failed' || result.png !== undefined || (await $.fs.exists(result.path))) return result
161 if (formulas.get(id) === known) formulas.delete(id)
162 }
163 let pending = formulas.get(id)
164 if (pending === undefined) {
165 // A rejection is an abandoned dispatch or a missing binary, so the next draw tries again.
166 pending = render($, tex, foreground, mode, bytes).catch(error => {
167 formulas.delete(id)
168 return { status: 'failed', reason: String(error).slice(0, 200) }
169 })
170 formulas.set(id, pending)
171 }
172 return pending
173}
174
175export const register: Register = (on, options) => {
176 settings = readSettings(options)
177
178 on('session.start', async ($, e, next) => {
179 const started = await next(e)
180 // Only a session that draws in the terminal needs the decision; -p and the SDK never draw.
181 if (e.surface === 'terminal') await drawing($)
182 return started
183 })
184
185 on('prompt.compose', async ($, e, next) => {
186 const composed = await next(e)
187 if (e.surfaces[0] !== 'terminal' || !settings.promptSection) return composed
188 if ((await drawing($)).kind !== 'images') return composed
189 return { sections: [...composed.sections, section(settings.inline)] }
190 })
191
192 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
193 if (e.surface !== 'terminal') return next(e)
194 const parts = segments(e.props.text)
195 const inlines = parts.flatMap(part => (part.kind === 'text' ? inlineMath(part.text) : []))
196 const displays = parts.flatMap(part => (part.kind === 'math' ? [part.tex] : []))
197 if (inlines.length + displays.length === 0) return next(e)
198 const runs = new Set([...displays, ...(settings.inline === 'image' ? inlines : [])]).size
199 const how = await drawing($)
200 if (how.kind === 'text' || runs > MAX_FORMULAS) {
201 return next({ ...e, props: { ...e.props, text: asText(e.props.text) } })
202 }
203
204 const pieces = parts.flatMap((part): Piece[] => {
205 if (part.kind === 'math') return [{ kind: 'display', tex: part.tex, source: part.source }]
206 return settings.inline === 'image' ? blocks(part.text) : [{ kind: 'markdown', text: inline(part.text) }]
207 })
208 if (pieces.every(piece => piece.kind === 'markdown')) {
209 const text = inline(e.props.text)
210 return next(text === e.props.text ? e : { ...e, props: { ...e.props, text } })
211 }
212
213 const { Box, Image, Markdown, Text } = $.ui.resolve(e)
214 const room = Math.max(8, (e.viewport?.columns ?? 80) - INDENT - 2)
215 const foreground = await currentColour($)
216
217 // Every formula is rendered before the tree is built, so the drawing below is synchronous.
218 const made = new Map<string, Formula>()
219 const wanted = pieces.flatMap((piece): [string, Mode][] => {
220 if (piece.kind === 'display') return [[piece.tex, 'display']]
221 if (piece.kind === 'markdown') return []
222 return piece.items.flatMap(item => item.tokens.flatMap((token): [string, Mode][] => (token.kind === 'math' ? [[token.tex, 'inline']] : [])))
223 })
224 const bytes = how.source === 'bytes'
225 await Promise.all(wanted.map(async ([tex, mode]) => made.set(tex, await formula($, tex, foreground, mode, bytes))))
226
227 const picture = (tex: string, source: string) => {
228 const result = made.get(tex)
229 if (result?.status !== 'ready') return undefined
230 const { columns, rows } = fit(result.cells, room)
231 const image = result.png === undefined ? { file: result.path, format: 'png' as const } : { png: result.png }
232 return <Image source={image} columns={columns} rows={rows} alt={source} />
233 }
234 const token = (one: Token) => (
235 <Box marginRight={one.space ? 1 : 0} flexShrink={0}>
236 {one.kind === 'word' ? (
237 <Text bold={one.bold} italic={one.italic} color={one.code ? 'permission' : undefined}>
238 {one.text}
239 </Text>
240 ) : (
241 (picture(one.tex, one.source) ?? <Text>{one.source}</Text>)
242 )}
243 </Box>
244 )
245 const item = (one: Item) => (
246 <Box flexDirection="row" paddingLeft={one.indent}>
247 {one.marker !== '' && (
248 <Box marginRight={1} flexShrink={0}>
249 <Text>{one.marker}</Text>
250 </Box>
251 )}
252 <Box flexDirection="row" flexWrap="wrap" flexGrow={1} flexShrink={1}>
253 {one.tokens.map(token)}
254 </Box>
255 </Box>
256 )
257 const draw = (piece: Piece) => {
258 if (piece.kind === 'markdown') return <Markdown text={piece.text} />
259 if (piece.kind === 'flow') return <Box flexDirection="column">{piece.items.map(item)}</Box>
260 const result = made.get(piece.tex)
261 return (
262 picture(piece.tex, piece.source) ?? (
263 <Box flexDirection="column">
264 <Text>{piece.source}</Text>
265 <Text dimColor>not rendered: {result?.status === 'failed' ? result.reason : 'no picture'}</Text>
266 </Box>
267 )
268 )
269 }
270
271 // The engine draws the reply's opening text itself, so the bullet and its spacing stay its own.
272 const first = pieces[0]
273 if (first?.kind === 'markdown') {
274 const head = await next({ ...e, props: { ...e.props, text: first.text } })
275 return (
276 <Box flexDirection="column" rowGap={1}>
277 {head}
278 <Box flexDirection="column" rowGap={1} paddingLeft={INDENT}>
279 {pieces.slice(1).map(draw)}
280 </Box>
281 </Box>
282 )
283 }
284 return (
285 <Box flexDirection="row" marginTop={1}>
286 <Box width={INDENT} flexShrink={0}>
287 {e.props.isFirstOfReply && <Text>●</Text>}
288 </Box>
289 <Box flexDirection="column" rowGap={1} flexGrow={1} flexShrink={1}>
290 {pieces.map(draw)}
291 </Box>
292 </Box>
293 )
294 })
295}
296hooks/cache.ts 26 lines1import type { FsEntry } from 'claude-code'
2
3/**
4 * The files to delete so the picture cache fits in `maxBytes`: whole pictures, a `.png` and its `.cells`,
5 * least recently used first. The renderer touches both files on every use. Other files are left alone.
6 */
7export function overflow(entries: readonly FsEntry[], maxBytes: number): string[] {
8 const pictures = new Map<string, { bytes: number; usedAt: number; names: string[] }>()
9 for (const entry of entries) {
10 const stem = entry.kind === 'file' ? entry.name.match(/^([0-9a-f]+)\.(?:png|cells)$/)?.[1] : undefined
11 if (stem === undefined) continue
12 const picture = pictures.get(stem) ?? { bytes: 0, usedAt: 0, names: [] }
13 picture.bytes += entry.size
14 picture.usedAt = Math.max(picture.usedAt, entry.mtimeMs)
15 picture.names.push(entry.name)
16 pictures.set(stem, picture)
17 }
18 let kept = 0
19 const doomed: string[] = []
20 for (const picture of [...pictures.values()].sort((a, b) => b.usedAt - a.usedAt)) {
21 kept += picture.bytes
22 if (kept > maxBytes) doomed.push(...picture.names)
23 }
24 return doomed
25}
26hooks/flow.ts 106 lines1// A paragraph that holds inline math is drawn word by word, so a one-row picture can sit in the line.
2
3import { inline, inlineMath } from './unicode'
4
5export type Token =
6 | { kind: 'word'; text: string; bold: boolean; italic: boolean; code: boolean; space: boolean }
7 /** `tex` is the body handed to LaTeX; `source` is the reply's own text, drawn when no picture can be. */
8 | { kind: 'math'; tex: string; source: string; space: boolean }
9
10/** One paragraph or one list item: `marker` is '' for a paragraph, `indent` its leading columns. */
11export type Item = { marker: string; indent: number; tokens: Token[] }
12
13export type Block =
14 /** Text the surface's own markdown renderer draws. */
15 | { kind: 'markdown'; text: string }
16 /** Paragraphs and list items with no blank line between them, drawn word by word. */
17 | { kind: 'flow'; items: Item[] }
18
19type Style = { bold: boolean; italic: boolean; code: boolean }
20
21// Code, inline math, bold and italic, in the order that decides a tie at one position.
22const SPAN =
23 /`([^`\n]+)`|\\\((.+?)\\\)|(?<![\\$\w])\$(?!\s)([^$\n]+?)(?<![\s\\])\$(?![\d$])|\*\*(?!\s)(.+?)(?<!\s)\*\*|(?<![\w*\\])\*(?!\s)([^*\n]+?)(?<!\s)\*(?![\w*])|(?<![\w\\])_(?!\s)([^_\n]+?)(?<!\s)_(?!\w)/g
24const ESCAPED = /\\([\\`*_{}[\]()#+\-.!|<>~$])/g
25const MARKER = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/
26// A heading, a quote, a table row, a fence or a rule: blocks whose layout stays the renderer's.
27const OTHER_BLOCK = /^\s*(#{1,6}\s|>|\||```|~~~|([-*_])(\s*\2){2,}\s*$)/
28
29function tokens(text: string, style: Style, out: Token[]): void {
30 const plain = (piece: string, wordStyle: Style, isEscaped: boolean) => {
31 const last = out.at(-1)
32 if (last !== undefined && /^\s/.test(piece)) last.space = true
33 for (const [, word = '', gap] of piece.matchAll(/(\S+)(\s*)/g)) {
34 out.push({ kind: 'word', text: isEscaped ? word.replace(ESCAPED, '$1') : word, ...wordStyle, space: gap !== '' })
35 }
36 }
37 let from = 0
38 for (const match of text.matchAll(SPAN)) {
39 const [whole, code, paren, dollar, bold, star, underscore] = match
40 plain(text.slice(from, match.index), style, true)
41 from = match.index + whole.length
42 const tex = paren ?? dollar
43 if (code !== undefined) plain(code, { ...style, code: true }, false)
44 else if (tex !== undefined) out.push({ kind: 'math', tex: `\\(${tex}\\)`, source: whole, space: false })
45 else if (bold !== undefined) tokens(bold, { ...style, bold: true }, out)
46 else tokens(star ?? underscore ?? '', { ...style, italic: true }, out)
47 }
48 plain(text.slice(from), style, true)
49}
50
51// A reply's text cut at its blank lines, a fenced block kept whole.
52function chunks(markdown: string): string[] {
53 const list: string[] = []
54 let lines: string[] = []
55 let fence: string | undefined
56 for (const line of markdown.split('\n')) {
57 const mark = line.match(/^\s*(```|~~~)/)?.[1]
58 if (mark !== undefined && (fence === undefined || fence === mark)) fence = fence === undefined ? mark : undefined
59 if (fence === undefined && mark === undefined && line.trim() === '') {
60 if (lines.length > 0) list.push(lines.join('\n'))
61 lines = []
62 } else lines.push(line)
63 }
64 if (lines.length > 0) list.push(lines.join('\n'))
65 return list
66}
67
68// The paragraphs and list items of one chunk, or undefined when it holds another kind of block.
69function items(chunk: string): Item[] | undefined {
70 const list: { marker: string; indent: number; text: string }[] = []
71 for (const line of chunk.split('\n')) {
72 const [, indent, marker, text] = line.match(MARKER) ?? []
73 if (marker !== undefined) {
74 list.push({ marker: /\d/.test(marker) ? marker : '-', indent: indent?.length ?? 0, text: text ?? '' })
75 continue
76 }
77 if (OTHER_BLOCK.test(line)) return undefined
78 const last = list.at(-1)
79 // A line with no marker continues the item above it.
80 if (last === undefined) list.push({ marker: '', indent: 0, text: line.trim() })
81 else last.text += ` ${line.trim()}`
82 }
83 return list.map(({ marker, indent, text }) => {
84 const out: Token[] = []
85 tokens(text, { bold: false, italic: false, code: false }, out)
86 return { marker, indent, tokens: out }
87 })
88}
89
90/**
91 * A piece of a reply's markdown as blocks: the paragraphs and lists that hold inline math are
92 * taken apart into words and formulas, and everything else stays markdown. Inline math in a
93 * block that stays markdown (a table, a heading, a quote) is written as Unicode.
94 */
95export function blocks(markdown: string): Block[] {
96 const list: Block[] = []
97 for (const chunk of chunks(markdown)) {
98 const flow = inlineMath(chunk).length > 0 ? items(chunk) : undefined
99 const last = list.at(-1)
100 if (flow !== undefined) list.push({ kind: 'flow', items: flow })
101 else if (last?.kind === 'markdown') last.text += `\n\n${inline(chunk)}`
102 else list.push({ kind: 'markdown', text: inline(chunk) })
103 }
104 return list
105}
106hooks/guard.ts 30 lines1// A formula comes from the model, and LaTeX can read any file that the user can read. So a formula
2// is rendered only when every command in it is on the list of math commands in commands.ts.
3//
4// The check holds for the preamble in bin/render.sh, where a backslash is the only way to name a
5// command and ^ is the only superscript character.
6
7import { COMMANDS, ENVIRONMENTS } from './commands'
8
9// One command as TeX reads it under LaTeX's category codes: a backslash and a run of letters, or a
10// backslash and one other character. Reading them in order keeps `\\frac` apart from `\frac`.
11const COMMAND = /\\(?:([A-Za-z]+)|[\s\S])?/g
12
13/** Why a formula is not rendered, or undefined when it has only commands from the list. */
14export function refusal(tex: string): string | undefined {
15 // TeX turns ^^ and what follows into another character before it reads commands.
16 if (tex.includes('^^')) return 'the ^^ notation is not allowed'
17 for (const match of tex.matchAll(COMMAND)) {
18 const name = match[1]
19 if (name === undefined) continue
20 if (!COMMANDS.has(name)) return `\\${name} is not on the list of math commands`
21 if (name === 'begin' || name === 'end') {
22 // \begin and \end run the command that their argument names, so the name is checked too.
23 const environment = tex.slice(match.index + match[0].length).match(/^\s*\{([A-Za-z]+\*?)\}/)?.[1]
24 if (environment === undefined) return `\\${name} has no plain environment name`
25 if (!ENVIRONMENTS.has(environment)) return `the environment ${environment} is not on the list`
26 }
27 }
28 return undefined
29}
30hooks/math.ts 79 lines1export type Cells = { columns: number; rows: number }
2
3export type Segment =
4 | { kind: 'text'; text: string }
5 /** `tex` is the body handed to LaTeX; `source` is the reply's own text, drawn when no picture can be. */
6 | { kind: 'math'; tex: string; source: string }
7
8// Environments that open display math themselves, so they are not wrapped in \[ \].
9const ENVIRONMENTS = 'equation|align|gather|multline|alignat|flalign|eqnarray'
10
11// Code comes first so math delimiters inside a fence or a code span stay text.
12// An unclosed fence runs to the end, which is the state of a reply that still streams.
13const TOKEN = new RegExp(
14 [
15 '(?:^|\\n)[ \\t]*(```|~~~)[\\s\\S]*?(?:\\n[ \\t]*\\1|$)',
16 '`[^`\\n]*`',
17 '\\$\\$([\\s\\S]+?)\\$\\$',
18 '\\\\\\[([\\s\\S]+?)\\\\\\]',
19 `(\\\\begin\\{(${ENVIRONMENTS})(\\*?)\\}[\\s\\S]+?\\\\end\\{\\5\\6\\})`,
20 ].join('|'),
21 'g',
22)
23const OPENS_ENVIRONMENT = new RegExp(`^\\\\begin\\{(?:${ENVIRONMENTS})\\*?\\}`)
24
25function body(inner: string): string {
26 // A blank line ends a paragraph, which TeX refuses inside display math.
27 const tex = inner.trim().replace(/\n\s*\n/g, '\n')
28 return OPENS_ENVIRONMENT.test(tex) ? tex : `\\[ ${tex} \\]`
29}
30
31/** A reply's markdown split at its display math: `$$…$$`, `\[…\]` and amsmath environments. */
32export function segments(markdown: string): Segment[] {
33 const list: Segment[] = []
34 let from = 0
35 const text = (to: number) => {
36 const piece = markdown.slice(from, to).replace(/^\s*\n|\n\s*$/g, '')
37 if (piece.trim() !== '') list.push({ kind: 'text', text: piece })
38 }
39 for (const match of markdown.matchAll(TOKEN)) {
40 const inner = match[2] ?? match[3] ?? match[4]
41 if (inner === undefined || inner.trim() === '') continue
42 text(match.index)
43 list.push({ kind: 'math', tex: body(inner), source: match[0] })
44 from = match.index + match[0].length
45 }
46 text(markdown.length)
47 return list
48}
49
50/** The file stem of one picture in the cache: a 53-bit hash (cyrb53) of everything that changes its pixels. */
51export function stemOf(foreground: string, tex: string): string {
52 const input = `1\n${foreground}\n${tex}`
53 let h1 = 0xdeadbeef
54 let h2 = 0x41c6ce57
55 for (let i = 0; i < input.length; i++) {
56 const code = input.charCodeAt(i)
57 h1 = Math.imul(h1 ^ code, 2654435761)
58 h2 = Math.imul(h2 ^ code, 1597334677)
59 }
60 h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909)
61 h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909)
62 return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(16).padStart(14, '0')
63}
64
65/** The picture's box, shrunk in proportion when it is wider than the room it has. */
66export function fit(cells: Cells, room: number): Cells {
67 if (cells.columns <= room) return cells
68 return { columns: room, rows: Math.max(1, Math.round((cells.rows * room) / cells.columns)) }
69}
70
71/** A theme's colour as six hex digits, or undefined for one that names no RGB value (`ansi:white`). */
72export function hexColour(colour: unknown): string | undefined {
73 if (typeof colour !== 'string') return undefined
74 const hex = colour.match(/^#([0-9a-fA-F]{6})$/)?.[1]
75 if (hex !== undefined) return hex.toLowerCase()
76 const rgb = colour.match(/^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/)
77 return rgb?.slice(1).map(part => Math.min(255, Number(part)).toString(16).padStart(2, '0')).join('')
78}
79hooks/settings.ts 34 lines1import type { PluginOptions } from 'claude-code'
2
3/** The plugin's options, as the user set them under /plugin, each one checked and in its unit. */
4export type Settings = {
5 /** auto: pictures where the terminal draws them; on: pictures everywhere; off: Unicode text. */
6 images: 'auto' | 'on' | 'off'
7 /** How inline math is drawn when pictures are on. */
8 inline: 'image' | 'unicode'
9 /** Whether Claude is told that this terminal typesets math. */
10 promptSection: boolean
11 /** The size of display formulas, as a multiple of the default, from 0.5 to 2. */
12 scale: number
13 /** The largest size of the picture cache, in bytes. */
14 cacheBytes: number
15}
16
17function oneOf<T extends string>(value: unknown, choices: readonly T[], fallback: T): T {
18 return choices.find(choice => choice === value) ?? fallback
19}
20
21function between(value: unknown, low: number, high: number, fallback: number): number {
22 return typeof value === 'number' && Number.isFinite(value) ? Math.min(high, Math.max(low, value)) : fallback
23}
24
25export function readSettings(options: PluginOptions): Settings {
26 return {
27 images: oneOf(options.images, ['auto', 'on', 'off'], 'auto'),
28 inline: oneOf(options.inline, ['image', 'unicode'], 'image'),
29 promptSection: options.promptSection !== false,
30 scale: between(options.scale, 0.5, 2, 1),
31 cacheBytes: between(options.cacheSizeMB, 1, 10000, 100) * 1024 * 1024,
32 }
33}
34hooks/terminal.ts 58 lines1import type { Settings } from './settings'
2
3/** The environment variables that say where Claude Code draws. */
4export type Terminal = {
5 TERM?: string
6 TERM_PROGRAM?: string
7 KITTY_WINDOW_ID?: string
8 TMUX?: string
9 STY?: string
10 SSH_CONNECTION?: string
11 SSH_TTY?: string
12 CLAUDE_CODE_SESSION_KIND?: string
13 CLAUDE_CODE_FORCE_TERMINAL_IMAGES?: string
14}
15
16/** How this session draws math, decided once when it starts. */
17export type Drawing =
18 /** `bytes` when the terminal is across ssh and cannot read this machine's files. */
19 | { kind: 'images'; source: 'file' | 'bytes' }
20 | { kind: 'text'; reason: 'setting' | 'terminal' }
21 | { kind: 'text'; reason: 'missing'; missing: string }
22
23/** Where Claude Code draws no kitty graphics at all: inside tmux or screen, and in a background session. */
24function refuses(env: Terminal): boolean {
25 if (env.CLAUDE_CODE_FORCE_TERMINAL_IMAGES) return false
26 return env.CLAUDE_CODE_SESSION_KIND === 'bg' || Boolean(env.TMUX) || Boolean(env.STY)
27}
28
29/**
30 * Claude Code's own rule for kitty graphics, read from the environment: pictures in kitty and Ghostty,
31 * none where it refuses them, and always when CLAUDE_CODE_FORCE_TERMINAL_IMAGES is set.
32 * Claude Code also asks the terminal for its version, which a mod cannot.
33 */
34export function drawsImages(env: Terminal): boolean {
35 if (env.CLAUDE_CODE_FORCE_TERMINAL_IMAGES) return true
36 if (refuses(env)) return false
37 return (
38 env.TERM_PROGRAM === 'ghostty' ||
39 env.TERM === 'xterm-ghostty' ||
40 (env.TERM ?? '').includes('kitty') ||
41 env.KITTY_WINDOW_ID !== undefined
42 )
43}
44
45/** `missing` runs the renderer's check only when pictures are wanted, and answers what it lacks, or ''. */
46export async function decide(
47 setting: Settings['images'],
48 env: Terminal,
49 missing: () => Promise<string>,
50): Promise<Drawing> {
51 if (setting === 'off') return { kind: 'text', reason: 'setting' }
52 // Where Claude Code refuses images it draws each picture's alt, the LaTeX source, so `on` cannot help there.
53 if (refuses(env) || (setting === 'auto' && !drawsImages(env))) return { kind: 'text', reason: 'terminal' }
54 const lacking = await missing()
55 if (lacking !== '') return { kind: 'text', reason: 'missing', missing: lacking }
56 return { kind: 'images', source: env.SSH_CONNECTION || env.SSH_TTY ? 'bytes' : 'file' }
57}
58hooks/unicode.ts 252 lines1// Math as Unicode text: `$\beta_0 \leq x^2$` becomes `β₀ ≤ x²`.
2
3import { segments } from './math'
4
5const SYMBOLS: Record<string, string> = {
6 alpha: 'α', beta: 'β', gamma: 'γ', delta: 'δ', epsilon: 'ϵ', varepsilon: 'ε', zeta: 'ζ', eta: 'η',
7 theta: 'θ', vartheta: 'ϑ', iota: 'ι', kappa: 'κ', lambda: 'λ', mu: 'μ', nu: 'ν', xi: 'ξ', pi: 'π',
8 varpi: 'ϖ', rho: 'ρ', varrho: 'ϱ', sigma: 'σ', varsigma: 'ς', tau: 'τ', upsilon: 'υ', phi: 'ϕ',
9 varphi: 'φ', chi: 'χ', psi: 'ψ', omega: 'ω',
10 Gamma: 'Γ', Delta: 'Δ', Theta: 'Θ', Lambda: 'Λ', Xi: 'Ξ', Pi: 'Π', Sigma: 'Σ', Upsilon: 'Υ',
11 Phi: 'Φ', Psi: 'Ψ', Omega: 'Ω',
12 cdot: '·', times: '×', div: '÷', pm: '±', mp: '∓', ast: '∗', star: '⋆', circ: '∘', bullet: '∙',
13 oplus: '⊕', ominus: '⊖', otimes: '⊗', odot: '⊙', wedge: '∧', land: '∧', vee: '∨', lor: '∨',
14 cap: '∩', cup: '∪', setminus: '∖', neg: '¬', lnot: '¬', dagger: '†',
15 leq: '≤', le: '≤', geq: '≥', ge: '≥', neq: '≠', ne: '≠', ll: '≪', gg: '≫', approx: '≈', sim: '∼',
16 simeq: '≃', cong: '≅', equiv: '≡', propto: '∝', doteq: '≐', prec: '≺', succ: '≻', preceq: '⪯',
17 succeq: '⪰', parallel: '∥', perp: '⊥', mid: '∣', nmid: '∤', models: '⊨', vdash: '⊢',
18 in: '∈', notin: '∉', ni: '∋', subset: '⊂', supset: '⊃', subseteq: '⊆', supseteq: '⊇',
19 emptyset: '∅', varnothing: '∅', forall: '∀', exists: '∃', nexists: '∄',
20 to: '→', rightarrow: '→', leftarrow: '←', gets: '←', leftrightarrow: '↔', Rightarrow: '⇒',
21 Leftarrow: '⇐', Leftrightarrow: '⇔', implies: '⟹', iff: '⟺', mapsto: '↦', longrightarrow: '⟶',
22 longleftarrow: '⟵', uparrow: '↑', downarrow: '↓', hookrightarrow: '↪', rightharpoonup: '⇀',
23 sum: '∑', prod: '∏', coprod: '∐', int: '∫', iint: '∬', iiint: '∭', oint: '∮', bigcup: '⋃',
24 bigcap: '⋂', bigoplus: '⨁', bigotimes: '⨂', bigvee: '⋁', bigwedge: '⋀',
25 infty: '∞', partial: '∂', nabla: '∇', hbar: 'ℏ', ell: 'ℓ', Re: 'ℜ', Im: 'ℑ', aleph: 'ℵ', wp: '℘',
26 top: '⊤', bot: '⊥', angle: '∠', triangle: '△', square: '□', Box: '□', degree: '°', prime: '′',
27 dots: '…', ldots: '…', cdots: '⋯', vdots: '⋮', ddots: '⋱',
28 langle: '⟨', rangle: '⟩', lVert: '‖', rVert: '‖', Vert: '‖', lvert: '|', rvert: '|', vert: '|',
29 lfloor: '⌊', rfloor: '⌋', lceil: '⌈', rceil: '⌉', lbrace: '{', rbrace: '}', backslash: '\\',
30 therefore: '∴', because: '∵', qed: '∎', surd: '√',
31 ',': ' ', ';': ' ', ':': ' ', ' ': ' ', '!': '', quad: ' ', qquad: ' ', '\\': ' ',
32 '{': '{', '}': '}', '%': '%', $: '$', '&': '&', _: '_', '#': '#', '|': '‖',
33}
34
35const FUNCTIONS = new Set(
36 'sin cos tan cot sec csc arcsin arccos arctan sinh cosh tanh log ln lg exp min max sup inf lim liminf limsup arg det dim ker deg gcd hom Pr mod'.split(
37 ' ',
38 ),
39)
40
41// Commands that change size or style, which plain text has no use for.
42const DROPPED = new Set(
43 'left right big Big bigg Bigg bigl bigr Bigl Bigr biggl biggr middle displaystyle textstyle scriptstyle limits nolimits nonumber'.split(
44 ' ',
45 ),
46)
47
48// Commands whose one argument is drawn as it stands.
49const PLAIN = new Set(
50 'text textrm textbf textit textsf texttt mathrm mathbf mathit mathsf mathtt mathnormal operatorname boldsymbol bm mbox'.split(
51 ' ',
52 ),
53)
54
55const ACCENTS: Record<string, string> = {
56 hat: '̂', widehat: '̂', tilde: '̃', widetilde: '̃', bar: '̄',
57 vec: '⃗', dot: '̇', ddot: '̈', check: '̌', breve: '̆',
58 overline: '̅', underline: '̲',
59}
60
61const SUPERSCRIPTS: Record<string, string> = {
62 0: '⁰', 1: '¹', 2: '²', 3: '³', 4: '⁴', 5: '⁵', 6: '⁶', 7: '⁷', 8: '⁸', 9: '⁹',
63 '+': '⁺', '-': '⁻', '=': '⁼', '(': '⁽', ')': '⁾',
64 a: 'ᵃ', b: 'ᵇ', c: 'ᶜ', d: 'ᵈ', e: 'ᵉ', f: 'ᶠ', g: 'ᵍ', h: 'ʰ', i: 'ⁱ', j: 'ʲ', k: 'ᵏ', l: 'ˡ',
65 m: 'ᵐ', n: 'ⁿ', o: 'ᵒ', p: 'ᵖ', r: 'ʳ', s: 'ˢ', t: 'ᵗ', u: 'ᵘ', v: 'ᵛ', w: 'ʷ', x: 'ˣ', y: 'ʸ',
66 z: 'ᶻ', T: 'ᵀ', '⊤': 'ᵀ', '′': '′', '∘': '°', '°': '°', '∗': '*', '*': '*', '†': '†',
67}
68
69const SUBSCRIPTS: Record<string, string> = {
70 0: '₀', 1: '₁', 2: '₂', 3: '₃', 4: '₄', 5: '₅', 6: '₆', 7: '₇', 8: '₈', 9: '₉',
71 '+': '₊', '-': '₋', '=': '₌', '(': '₍', ')': '₎',
72 a: 'ₐ', e: 'ₑ', h: 'ₕ', i: 'ᵢ', j: 'ⱼ', k: 'ₖ', l: 'ₗ', m: 'ₘ', n: 'ₙ', o: 'ₒ', p: 'ₚ', r: 'ᵣ',
73 s: 'ₛ', t: 'ₜ', u: 'ᵤ', v: 'ᵥ', x: 'ₓ', β: 'ᵦ', γ: 'ᵧ', ρ: 'ᵨ', φ: 'ᵩ', χ: 'ᵪ',
74}
75
76// The letters Unicode placed outside the run of their alphabet.
77const DOUBLE_STRUCK: Record<string, string> = { C: 'ℂ', H: 'ℍ', N: 'ℕ', P: 'ℙ', Q: 'ℚ', R: 'ℝ', Z: 'ℤ' }
78const SCRIPT: Record<string, string> = { B: 'ℬ', E: 'ℰ', F: 'ℱ', H: 'ℋ', I: 'ℐ', L: 'ℒ', M: 'ℳ', R: 'ℛ' }
79const ALPHABETS: Record<string, { first: number; outside: Record<string, string> }> = {
80 mathbb: { first: 0x1d538, outside: DOUBLE_STRUCK },
81 mathcal: { first: 0x1d49c, outside: SCRIPT },
82 mathscr: { first: 0x1d49c, outside: SCRIPT },
83}
84
85// Operators written against their operand, as TeX sets them: `\partial f` is `∂f`.
86const PREFIXES = new Set(['partial', 'nabla', 'neg', 'lnot'])
87
88const ROOTS: Record<string, string> = { '': '√', 2: '√', 3: '∛', 4: '∜' }
89
90const TOKEN = /\\[a-zA-Z]+|\\[^a-zA-Z]|[{}^_]|\s+|[^\\{}^_\s]/gu
91const SPACE = /^\s+$/
92
93/** Thrown at a construct plain text cannot hold: an environment, an unknown command. */
94class Unsupported extends Error {}
95
96function isAtomic(text: string): boolean {
97 return !/[\s+\-−=<>≤≥·×/,]/.test(text)
98}
99
100function script(mark: '^' | '_', argument: string): string {
101 const table = mark === '^' ? SUPERSCRIPTS : SUBSCRIPTS
102 const characters = [...argument.replace(/\s+/g, '')]
103 if (characters.every(character => table[character] !== undefined)) {
104 return characters.map(character => table[character]).join('')
105 }
106 return characters.length === 1 ? `${mark}${characters[0]}` : `${mark}(${argument.trim()})`
107}
108
109function accent(name: string, argument: string): string {
110 const characters = [...argument]
111 const mark = ACCENTS[name] ?? ''
112 if (name === 'overline' || name === 'underline') return characters.map(one => one + mark).join('')
113 return characters.length === 1 ? argument + mark : `${name}(${argument})`
114}
115
116function alphabet(name: string, argument: string): string {
117 const { first, outside } = ALPHABETS[name] ?? { first: 0, outside: {} }
118 return [...argument]
119 .map(one => {
120 if (outside[one] !== undefined) return outside[one]
121 if (one >= 'A' && one <= 'Z') return String.fromCodePoint(first + one.charCodeAt(0) - 65)
122 return one === '1' && name === 'mathbb' ? '𝟙' : one
123 })
124 .join('')
125}
126
127/** The formula as Unicode text, or undefined when plain text cannot hold it. */
128export function unicode(tex: string): string | undefined {
129 const tokens = tex.match(TOKEN) ?? []
130 let at = 0
131
132 const skipSpace = () => {
133 while (SPACE.test(tokens[at] ?? '')) at++
134 }
135 const sequence = (until: string): string => {
136 let out = ''
137 while (at < tokens.length && tokens[at] !== until) out += atom()
138 at++
139 return out
140 }
141 // What a command or a script takes: a braced group, or the next single token.
142 const argument = (): string => {
143 skipSpace()
144 return at < tokens.length ? atom() : ''
145 }
146 const root = (): string => {
147 skipSpace()
148 let degree = ''
149 if (tokens[at] === '[') {
150 at++
151 degree = sequence(']').trim()
152 }
153 const sign = ROOTS[degree] ?? `${script('^', degree)}√`
154 const under = argument()
155 return isAtomic(under) ? sign + under : `${sign}(${under})`
156 }
157 const atom = (): string => {
158 const token = tokens[at++] ?? ''
159 if (token === '{') return sequence('}')
160 if (token === '^' || token === '_') return script(token, argument())
161 if (token === "'") return '′'
162 if (SPACE.test(token)) return ' '
163 if (token[0] !== '\\') return token
164
165 const name = token.slice(1)
166 if (name === 'begin' || name === 'end') throw new Unsupported(name)
167 if (DROPPED.has(name)) {
168 // `\left.` and `\right.` are delimiters that draw nothing.
169 if ((name === 'left' || name === 'right') && tokens[at] === '.') at++
170 return ''
171 }
172 if (PLAIN.has(name)) {
173 if (tokens[at] === '*') at++
174 return argument()
175 }
176 if (name === 'frac' || name === 'dfrac' || name === 'tfrac') {
177 const [over, under] = [argument(), argument()].map(part => (isAtomic(part) ? part : `(${part})`))
178 return `${over}/${under}`
179 }
180 if (name === 'binom') return `C(${argument()}, ${argument()})`
181 if (name === 'sqrt') return root()
182 if (ACCENTS[name] !== undefined) return accent(name, argument())
183 if (ALPHABETS[name] !== undefined) return alphabet(name, argument())
184 if (FUNCTIONS.has(name)) return name
185 if (PREFIXES.has(name)) skipSpace()
186 const symbol = SYMBOLS[name]
187 // An unknown command would be drawn wrong, so the formula keeps its source.
188 if (symbol === undefined) throw new Unsupported(name)
189 return symbol
190 }
191
192 try {
193 let out = ''
194 while (at < tokens.length) out += atom()
195 return out
196 .replace(/\s+/g, ' ')
197 .replace(/([(⟨⌊⌈]) | (?=[)⟩⌋⌉])/g, '$1')
198 .trim()
199 } catch (error) {
200 if (error instanceof Unsupported) return undefined
201 throw error
202 }
203}
204
205// Code comes first so a dollar sign inside a fence or a code span stays text. A dollar opens
206// math only before a non-space and closes it only after one, so "$5 and $10" stays text.
207const INLINE =
208 /(?:^|\n)[ \t]*(```|~~~)[\s\S]*?(?:\n[ \t]*\1|$)|`[^`\n]*`|\\\((.+?)\\\)|(?<![\\$\w])\$(?!\s)([^$\n]+?)(?<![\s\\])\$(?![\d$])/g
209
210/** The TeX of each inline formula in a piece of a reply's markdown, outside its code. */
211export function inlineMath(markdown: string): string[] {
212 const found: string[] = []
213 for (const match of markdown.matchAll(INLINE)) {
214 const tex = match[2] ?? match[3]
215 if (tex !== undefined) found.push(tex)
216 }
217 return found
218}
219
220// The result is markdown again, so the characters markdown reads as markup are escaped.
221const escape = (text: string) => text.replace(/[\\*_`~[\]<>|]/g, '\\$&')
222
223// At the start of a line these make a list item or a heading: `- x`, `+ x`, `# x`, `1. x`.
224const escapeLineStart = (text: string) => text.replace(/^([-+#])/, '\\$1').replace(/^(\d+)([.)])/, '$1\\$2')
225
226/** A piece of a reply's markdown with its inline math, `$…$` and `\(…\)`, written as Unicode. */
227export function inline(markdown: string): string {
228 return markdown.replace(INLINE, (whole: string, ...groups: (string | number | undefined)[]) => {
229 const [, paren, dollar, offset] = groups as [string | undefined, string | undefined, string | undefined, number]
230 const tex = paren ?? dollar
231 if (tex === undefined) return whole
232 const text = unicode(tex)
233 if (text === undefined) return whole
234 return /(^|\n)[ \t]*$/.test(markdown.slice(0, offset)) ? escapeLineStart(escape(text)) : escape(text)
235 })
236}
237
238/**
239 * A reply's markdown with all of its math as text: inline and display math in Unicode, and display
240 * math that Unicode cannot hold (an environment, an unknown command) as a LaTeX code block.
241 */
242export function asText(markdown: string): string {
243 return segments(markdown)
244 .map(part => {
245 if (part.kind === 'text') return inline(part.text)
246 const body = part.tex.match(/^\\\[ ([\s\S]*) \\\]$/)?.[1]
247 const text = body === undefined ? undefined : unicode(body)
248 return text === undefined ? `\`\`\`latex\n${body ?? part.tex}\n\`\`\`` : escapeLineStart(escape(text))
249 })
250 .join('\n\n')
251}
252hooks/commands.ts 85 lines1// Written by tools/commands.py generate. Do not edit: change the lists in that file and run it again.
2// Built from pdfTeX 3.141592653-2.6-1.40.29 (TeX Live 2026/Arch Linux).
3// 399 symbols, 107 composite symbols, 227 structural commands and 27 primitives.
4
5const words = (...rows: string[]) => new Set(rows.join('').trim().split(' '))
6
7/** The commands that a formula may use, without their backslash. */
8export const COMMANDS: ReadonlySet<string> = words(
9 'Aboxed AmS And Approxcolon Arrowvert Bbbk Big Bigg Biggl Biggm Biggr Bigl Bigm Bigr Box Bumpeq Cap ',
10 'Colonapprox Colondash Coloneq Coloneqq Colonsim Cup Dashcolon Delta Diamond Doteq Downarrow Eqcolon ',
11 'Eqqcolon Finv Game Gamma Im Join Lambda Leftarrow Leftrightarrow Lleftarrow Longleftarrow ',
12 'Longleftrightarrow Longrightarrow Lsh Omega Phi Pi Pr Psi Re Relbar Rightarrow Rrightarrow Rsh Sigma ',
13 'Simcolon Subset Supset Theta Uparrow Updownarrow Upsilon Vdash Vert Vvdash Xi acute adjustlimits aleph ',
14 'allowbreak alpha amalg angle approx approxcolon approxeq arccos arcsin arctan arg arraystretch arrowvert ',
15 'ast asymp atop backepsilon backprime backsim backsimeq backslash bar barwedge because begin beta beth ',
16 'between big bigcap bigcirc bigcup bigg biggl biggm biggr bigl bigm bigodot bigoplus bigotimes bigr ',
17 'bigsqcup bigstar bigtimes bigtriangledown bigtriangleup biguplus bigvee bigwedge binom blacklozenge ',
18 'blacksquare blacktriangle blacktriangledown blacktriangleleft blacktriangleright bm bmod boldsymbol bot ',
19 'bowtie boxdot boxed boxminus boxplus boxtimes brace braceld bracelu bracerd braceru bracevert brack ',
20 'breve bullet bumpeq cap cdot cdotp cdots centerdot cfrac check checkmark chi choose circ circeq ',
21 'circlearrowleft circlearrowright circledR circledS circledast circledcirc circleddash clap cline ',
22 'clubsuit colon colonapprox colondash coloneq coloneqq colonsim complement cong coprod cos cosh cot coth ',
23 'cramped csc cup curlyeqprec curlyeqsucc curlyvee curlywedge curvearrowleft curvearrowright dagger daleth ',
24 'dasharrow dashcolon dashleftarrow dashrightarrow dashv dbinom dblcolon ddagger ddddot dddot ddot ddots ',
25 'defaultscriptratio defaultscriptscriptratio deg delta det dfrac diagdown diagup diamond diamondsuit ',
26 'digamma dim displaybreak displaylimits displaystyle div divideontimes dot doteq doteqdot dotplus dots ',
27 'dotsb dotsc dotsi dotsm dotso doublebarwedge doublecap doublecup downarrow downdownarrows ',
28 'downharpoonleft downharpoonright ell emph emptyset end enskip enspace ensuremath epsilon eqcirc eqcolon ',
29 'eqqcolon eqref eqsim eqslantgtr eqslantless equiv eta eth exists exp fallingdotseq fbox flat forall frac ',
30 'frown gamma gcd ge genfrac geq geqq geqslant gets gg ggg gggtr gimel gnapprox gneq gneqq gnsim grave ',
31 'gtrapprox gtrdot gtreqless gtreqqless gtrless gtrsim gvertneqq hat hbar hbox hdots hdotsfor heartsuit ',
32 'hfil hfill hline hom hookleftarrow hookrightarrow hphantom hskip hslash hspace idotsint iff iiiint iiint ',
33 'iint imath impliedby implies in inf infty injlim int intercal intertext intop iota jmath joinrel kappa ',
34 'ker kern l lVert label lambda land langle lbrace lceil ldotp ldots le leadsto left leftarrow ',
35 'leftarrowtail leftharpoondown leftharpoonup leftleftarrows leftrightarrow leftrightarrows ',
36 'leftrightharpoons leftrightsquigarrow leftthreetimes leq leqq leqslant lessapprox lessdot lesseqgtr ',
37 'lesseqqgtr lessgtr lesssim lfloor lg lgroup lhd lhook lim liminf limits limsup ll llap llcorner lll ',
38 'llless lmoustache ln lnapprox lneq lneqq lnot lnsim log longleftarrow longleftrightarrow longmapsto ',
39 'longrightarrow looparrowleft looparrowright lor lozenge lparen lq lrcorner ltimes lvert lvertneqq ',
40 'maltese mapsto mapstochar mathbb mathbf mathbin mathcal mathchoice mathclap mathclose mathdollar ',
41 'mathellipsis mathfrak mathinner mathit mathllap mathnormal mathop mathopen mathord mathparagraph ',
42 'mathpunct mathrel mathring mathrlap mathrm mathsection mathsf mathsterling mathstrut mathtt ',
43 'mathunderscore max mbox measuredangle medspace mho mid middle min minalignsep mkern mod models mp mskip ',
44 'mspace mu multicolumn multimap nLeftarrow nLeftrightarrow nRightarrow nVDash nVdash nabla natural ncong ',
45 'ndownarrow ne nearrow neg negmedspace negthickspace negthinspace neq nexists ngeq ngeqq ngeqslant ngtr ',
46 'ni nleftarrow nleftrightarrow nleq nleqq nleqslant nless nmid nolimits nonumber not notag notin ',
47 'nparallel nprec npreceq nrightarrow nshortmid nshortparallel nsim nsubseteq nsubseteqq nsucc nsucceq ',
48 'nsupseteq nsupseteqq ntriangleleft ntrianglelefteq ntriangleright ntrianglerighteq nu nuparrow nvDash ',
49 'nvdash nwarrow odot oint ointop omega ominus operatorname operatornamewithlimits oplus ordinarycolon ',
50 'oslash otimes over overbrace overbracket overleftarrow overleftrightarrow overline overrightarrow ',
51 'overset overunderset owns parallel partial perp phantom phi pi pitchfork pm pmb pmod pod prec precapprox ',
52 'preccurlyeq preceq precnapprox precneqq precnsim precsim prescript prime prod projlim propto psi qquad ',
53 'quad rVert rangle rbrace rbrack rceil ref relax relbar restriction rfloor rgroup rhd rho rhook right ',
54 'rightarrow rightarrowtail rightharpoondown rightharpoonup rightleftarrows rightleftharpoons ',
55 'rightrightarrows rightsquigarrow rightthreetimes risingdotseq rlap rmoustache rparen rq rtimes rvert ',
56 'scriptscriptstyle scriptstyle searrow sec setminus sharp shortintertext shortmid shortparallel shoveleft ',
57 'shoveright sideset sigma sim simcolon simeq sin sinh smallfrown smallint smallsetminus smallsmile smash ',
58 'smashoperator smile spadesuit sphericalangle sqcap sqcup sqrt sqsubset sqsubseteq sqsupset sqsupseteq ',
59 'square stackrel star strut subset subseteq subseteqq subsetneq subsetneqq substack succ succapprox ',
60 'succcurlyeq succeq succnapprox succneqq succnsim succsim sum sup supset supseteq supseteqq supsetneq ',
61 'supsetneqq surd swarrow tag tan tanh tau tbinom text textbf textellipsis textit textnormal textrm textsf ',
62 'textstyle texttt textup tfrac theequation thepage theparentequation therefore theta thickapprox thicksim ',
63 'thickspace thinspace tilde times to top triangle triangledown triangleleft trianglelefteq triangleq ',
64 'triangleright trianglerighteq twoheadleftarrow twoheadrightarrow ulcorner underbrace underbracket ',
65 'underleftarrow underleftrightarrow underline underrightarrow underset unlhd unrhd uparrow updownarrow ',
66 'upharpoonleft upharpoonright uplus upsilon upuparrows urcorner vDash varDelta varGamma varLambda ',
67 'varOmega varPhi varPi varPsi varSigma varTheta varUpsilon varXi varbigtriangledown varbigtriangleup ',
68 'varepsilon varinjlim varkappa varliminf varlimsup varnothing varphi varpi varprojlim varpropto varrho ',
69 'varsigma varsubsetneq varsubsetneqq varsupsetneq varsupsetneqq vartheta vartriangle vartriangleleft ',
70 'vartriangleright vcentcolon vdash vdots vec vee veebar vert vphantom wedge widehat widetilde wp wr ',
71 'xLeftarrow xLeftrightarrow xRightarrow xhookleftarrow xhookrightarrow xi xleftarrow xleftharpoondown ',
72 'xleftharpoonup xleftrightarrow xleftrightharpoons xmapsto xrightarrow xrightharpoondown xrightharpoonup ',
73 'xrightleftharpoons yen zeta ',
74)
75
76/** The environments that a formula may use. */
77export const ENVIRONMENTS: ReadonlySet<string> = words(
78 'equation equation* align align* gather gather* multline multline* alignat alignat* flalign flalign* ',
79 'eqnarray eqnarray* split aligned gathered alignedat multlined lgathered rgathered cases cases* dcases ',
80 'dcases* rcases rcases* drcases drcases* matrix pmatrix bmatrix Bmatrix vmatrix Vmatrix matrix* pmatrix* ',
81 'bmatrix* Bmatrix* vmatrix* Vmatrix* smallmatrix psmallmatrix bsmallmatrix Bsmallmatrix vsmallmatrix ',
82 'Vsmallmatrix smallmatrix* psmallmatrix* bsmallmatrix* Bsmallmatrix* vsmallmatrix* Vsmallmatrix* array ',
83 'subarray spreadlines subequations ',
84)
85