SLOPSHOPPER

latex-math

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

newrowspromptprocess
★ 1v0.3.0MITupdated 2026-10-05atomashevic/claude-latex-math
A shopper browsing a rack in a slop shop
README

Claude LaTeX Math

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.

Claude Code in Ghostty: a prompt asks about attention, and the formulas in the reply appear typeset, inline and on their own lines

Install

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.

Requirements

  • Claude Code 2.1.287 or later, which runs mods. The mod is tested with 2.1.289.
  • A terminal with the kitty graphics protocol: kitty or Ghostty. In any other terminal, the mod writes math as Unicode text.
  • latex, dvipng and kpsewhich from TeX Live, with the LaTeX packages amsmath, amssymb, mathtools, bm and preview.
  • ImageMagick 7 (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:

SystemCommandTested
Arch Linuxsudo pacman -S texlive-bin texlive-basic texlive-latex texlive-latexrecommended texlive-latexextra imagemagickYes, with Ghostty 1.3.1
Debian, Ubuntusudo apt install texlive-latex-recommended dvipng preview-latex-style imagemagickThe renderer, on Ubuntu 24.04
macOSbrew install --cask basictex, then sudo tlmgr install dvipng preview mathtools and brew install imagemagickNo

Windows is not supported, because the renderer is a bash script.

Try it

These prompts show what the mod does:

  1. 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.
  2. 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.
  3. Write Maxwell's equations in differential form and explain each term. The reply has four display formulas and inline vector operators in the explanations.

Settings

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.

OptionValuesDefaultWhat it does
imagesauto, on, offautoauto 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.
inlineimage, unicodeimageHow inline math is drawn when images are on.
promptSectiontrue, falsetrueWhether the system prompt tells Claude that math is typeset.
scale0.5 to 21The size of display math, as a multiple of the default size.
cacheSizeMB1 to 10000100The largest size of the image cache, in MB.

What you see

  • Display math. $$ ... $$, \[ ... \], 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.
  • Inline math. $ ... $ 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.
  • Inline math in other blocks. In a table, a heading or a quote, the mod writes inline math as Unicode text. For example, $\beta_0 \leq x^2$ becomes β₀ ≤ x².
  • Colour. Each formula has the text colour of the Claude Code theme. With a custom theme, the mod reads text from the theme file. With a built-in theme, it uses the foreground colour of the terminal.
  • Formulas that are not rendered. A formula that LaTeX rejects, or that has a command outside the list of math commands, shows its source. A display formula also shows the reason below the source.
  • Text that stays text. Prices such as $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.

What the mod tells Claude

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.

How it works

  1. When a session starts in the terminal, the mod decides once whether to draw images. It reads the terminal from environment variables, as Claude Code does, and runs bin/render.sh --check to find missing tools. Then it deletes the least recently used images until the cache is under cacheSizeMB.
  2. A hook on each assistant message splits the reply into text, display formulas and inline formulas.
  3. The mod checks each formula against its list of math commands, and sends it to the renderer only when every command is on the list.
  4. 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.
  5. The hook draws each PNG with the 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.
  6. A paragraph that has inline math is drawn word by word in a wrapping row, so the image can sit between two words.

On the development machine, a new formula takes about 0.2 seconds and a cached formula takes about 5 milliseconds.

Troubleshooting

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.

Limits

  • A link in a paragraph that has inline math shows as Markdown source, text.
  • The baseline of an inline formula assumes the metrics of a typical monospace font. The value is BASELINE in bin/render.sh.
  • After a theme change, a formula that is already on screen keeps its colour until Claude Code draws the message again.
  • The Unicode fallback keeps the LaTeX source of a formula that has an environment or an unknown command.
  • The mod renders only the math commands of LaTeX, amsmath, amssymb, mathtools and bm. A formula that defines a macro, with \newcommand or \def, is not rendered.
  • The mod reads the terminal from environment variables. Claude Code also asks the terminal for its version, which a mod cannot do. In a kitty older than 0.28, the mod chooses images, and Claude Code shows each formula as dim LaTeX source.

Privacy

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.

What the mod runs, reads and sends

The mod makes no network requests and downloads nothing. Everything in this section stays on your computer.

What its hooks change

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.

Programs it runs

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.

CommandWhenWhy
bash <plugin>/bin/render.sh <cache folder> <stem> <colour> <display or inline> <scale>Once for each new formulaTypesets 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 --checkOnce when a terminal session startsLists 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 cacheSizeMBDeletes the least recently used images. It deletes only files in the cache folder whose names the renderer made.
ghostty +show-configIn Ghostty, at most once every 5 seconds while it draws repliesReads 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.

Formulas are untrusted input

A formula comes from the model, and LaTeX is a programming language. The mod limits what a formula can do in four ways.

  • Only math commands. The mod sends a formula to LaTeX only when every command in it is on a list of about 760 math commands and 57 environments, in 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.
  • No programs. latex runs with shell escape off (-no-shell-escape), and dvipng runs with Ghostscript off (--nogs).
  • No writes outside the work folder. 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.
  • Limits on time and size. 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.

What it sends, and where

  • The LaTeX of each formula goes to bin/render.sh on its standard input.
  • Each image goes to the terminal through Claude Code's Image element: as a file path, or as PNG bytes over SSH.
  • When a tool is missing, one notice line goes to the transcript with $.ui.log. Claude does not see it.
  • The system prompt section goes to Claude inside Claude Code's own requests to Anthropic. The mod adds the section and sends nothing itself.

What it reads

  • Claude's replies, as Claude Code draws them.
  • Two kinds of file, with $.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.
  • The theme row of /config, with $.config.list. It does not read the rest of your settings.
  • The output of ghostty +show-config, from which it takes the foreground colour.
  • The environment variables 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 file list of the cache folder, and whether an image file still exists.

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.

Security

To report a vulnerability, see SECURITY.md.

Support

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.

Development

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.

Credits

The method comes from claude-image-view by Jarrod Watts, which draws pasted images with the same Image element.

License

MIT. See LICENSE.

Source 9 files
hooks/register.tsx 296 lines
1import 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}
296
hooks/cache.ts 26 lines
1import 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}
26
hooks/flow.ts 106 lines
1// 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}
106
hooks/guard.ts 30 lines
1// 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}
30
hooks/math.ts 79 lines
1export 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}
79
hooks/settings.ts 34 lines
1import 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}
34
hooks/terminal.ts 58 lines
1import 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}
58
hooks/unicode.ts 252 lines
1// 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}
252
hooks/commands.ts 85 lines
1// 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