SLOPSHOPPER

mathcat

Draw a formula and show the PNG in a pane

newpaneguardcommandtoolprocess
v0.1.0Do No Harmupdated 2026-10-02johndpope/mathcat
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mathcat
│ ┃ mathcat ✕ › fix the failing auth test and add an audit log call │ ┃ No formula yet. │ ┃ ⏺ Read(src/auth.ts) │ ┃ /mathcat e^{i\pi}+1=0 ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Prev Next Drop ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ TeX: e^{i\pi}+1=0 ⏎ draw ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /mathcat │ ⎿ mathcat: No formula yet. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · mathcat
No formula yet. /mathcat e^{i\pi}+1=0 Prev Next Drop TeX: e^{i\pi}+1=0 ⏎ draw
README

English · <a href="README.ja.md">日本語</a>

<img src="docs/hero.jpg" alt="Pixel-art thriller: a laptop in the rain, a ribbon of light, and quantum formulas across the frame" width="100%">

<h1 align="center">mathcat</h1>

One math formula, drawn as a PNG.<br> macOS and Ubuntu. No TeX install.

Install

Python 3.10 or newer.

python3 -m pip install matplotlib
python3 -m pip install git+https://github.com/johndpope/mathcat.git
mathcat 'e^{i\pi}+1=0'

mathcat lands on your PATH, usually at ~/.local/bin. On iTerm2, pipe it if you want the picture inline:

mathcat 'e^{i\pi}+1=0' | imgcat

<img src="docs/euler.png" alt="Euler's identity" width="280">

mathcat --legend '\psi(x)=\int_{-\infty}^{\infty}\hat{\psi}(\xi)\,e^{2\pi i x \xi}\,d\xi'

<img src="docs/legend.png" alt="A formula with a colored ledger for psi, xi, and pi, then the integral limits, psi hat, and the exponent" width="640">

--note adds a short line in fine print under the ledger. Repeat it for another line. $...$ inside a note draws as math.

mathcat --legend \
  --note '$\mu$ is the center of the bell.' \
  --note '$\sigma$ is how wide the bell is.' \
  'f(x)=\frac{1}{\sigma\sqrt{2\pi}}\exp\left(-\frac{(x-\mu)^{2}}{2\sigma^{2}}\right)'

<img src="docs/note.png" alt="A Gaussian with a colored ledger and two short notes under it" width="640">

Claude Code

A pane for the formulas. The terminal paints the PNG. The mod keeps the path.

claude --plugin-dir /path/to/mathcat
/mathcat
/mathcat e^{i\pi}+1=0
/mathcat --legend 'e^{i\pi}+1=0'
/mathcat drop

/mathcat opens the pane. A formula is drawn and kept. Prev, Next, and Drop walk that list. --legend and --note are the same flags as the command line.

Ghostty or kitty, and Claude Code 2.1.287 or later. On another surface the pane shows the formula and the path.

For an LLM

Do not invent a workflow from this page. Load the mathcat skill and follow it.

They are the same file. Install both so a chat in either tool can run it:

mkdir -p ~/.grok/skills ~/.claude/skills
ln -sfn /path/to/mathcat/.grok/skills/mathcat ~/.grok/skills/mathcat
ln -sfn /path/to/mathcat/.grok/skills/mathcat ~/.claude/skills/mathcat

Asking to see a formula selects the skill. With the pane installed, /mathcat draws into it.

People who want the flags, the ledger, the fine-print notes, and the pictures: USAGE.md.

License

Copyright 2026 John D. Pope.

Do No Harm License (pre 1.0), the license at raisely/NoHarm.

Source 2 files
hooks/register.js 281 lines
1// mathcat: draw a formula to a PNG and show that file in a pane.
2// The terminal reads the file. This module keeps the path.
3
4import { cellBox, drawArgv, parse, pngSize, screenPng } from './formula.js'
5
6const PANE = 'mathcat'
7const STORE = 'entries'
8const DIR = '/tmp/mathcat-pane'
9const SHOW = 'mcp__mathcat__show'
10
11let entries = []
12let index = 0
13let note = ''
14
15export function register(on) {
16  on('session.start', async ($, e, next) => {
17    await $.command.register({
18      name: 'mathcat',
19      description: 'Draw a formula and show it',
20      argumentHint: '[formula | --legend | --note | drop]',
21      immediate: true,
22    })
23    await $.tool.register({
24      name: 'show',
25      description: 'Draw one mathtext formula and show the PNG in the mathcat pane.',
26      inputSchema: {
27        type: 'object',
28        properties: {
29          tex: { type: 'string', description: 'One formula. No preamble.' },
30          legend: { type: 'boolean', description: 'Add the English-name ledger.' },
31          notes: {
32            type: 'array',
33            items: { type: 'string' },
34            description: 'Short fine-print lines under the formula.',
35          },
36        },
37        required: ['tex'],
38      },
39    })
40    await load($)
41    return next(e)
42  })
43
44  on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
45    await load($)
46    return next(e)
47  })
48
49  on('command.run', { command: 'mathcat' }, async ($, e) => {
50    const text = await run($, e.args || '')
51    await present($)
52    return { text }
53  })
54
55  on('tool.call', { tool: SHOW }, async ($, e) => {
56    const text = await draw($, jobFromTool(e))
57    await present($)
58    return { result: text }
59  })
60
61  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
62    if (e.requestId !== PANE) return next(e)
63    const { Box, Text, Button, Input, Image } = $.ui.resolve(e)
64    const current = entries[index]
65    const fitted = current ? cellBox(e.props.bodyColumns, current.width, current.height) : null
66    const picture =
67      current && e.surface === 'terminal' && Image
68        ? Image({
69            key: 'view',
70            source: { file: current.path, format: 'png', generation: current.generation },
71            columns: fitted.columns,
72            rows: fitted.rows,
73            alt: current.tex.slice(0, 200),
74          })
75        : Text({ children: [current ? current.tex : 'No formula yet.'] })
76    const where = current
77      ? Text({ dimColor: true, children: [index + 1 + '/' + entries.length + '  ' + current.tex] })
78      : Text({ dimColor: true, children: ['/mathcat e^{i\\pi}+1=0'] })
79    return Box({
80      flexDirection: 'column',
81      gap: 1,
82      children: [
83        picture,
84        where,
85        ...(current ? [Text({ dimColor: true, children: [current.path] })] : []),
86        ...(note ? [Text({ children: [note] })] : []),
87        Box({
88          flexDirection: 'row',
89          columnGap: 2,
90          children: [
91            Button({ key: 'prev', label: 'Prev', plain: true, onPress: () => shift($, -1) }),
92            Button({ key: 'next', label: 'Next', plain: true, onPress: () => shift($, 1) }),
93            Button({ key: 'drop', label: 'Drop', plain: true, onPress: () => remove($) }),
94          ],
95        }),
96        Input({
97          key: 'draw',
98          label: 'TeX',
99          placeholder: 'e^{i\\pi}+1=0',
100          value: '',
101          submitLabel: 'draw',
102          onSubmit: (value) => run($, value),
103        }),
104      ],
105    })
106  })
107}
108
109async function present($) {
110  await $.ui.open({ id: PANE, title: 'mathcat', focus: true, closeOnEscape: true, rows: 28 })
111}
112
113async function load($) {
114  const stored = await $.store.get(STORE)
115  entries = Array.isArray(stored) ? stored.filter(isEntry) : []
116  const storedIndex = await $.store.get('index')
117  index = Number.isInteger(storedIndex) ? storedIndex : 0
118  if (entries.length === 0) index = 0
119  else if (index < 0 || index >= entries.length) index = 0
120}
121
122async function save($) {
123  await $.store.set(STORE, entries)
124  await $.store.set('index', index)
125}
126
127function isEntry(value) {
128  return !!value && typeof value.path === 'string' && typeof value.tex === 'string' && Number.isInteger(value.generation)
129}
130
131function jobFromTool(event) {
132  const input = event.input && typeof event.input === 'object' ? event.input : event
133  const parsed = parse('-- ' + String(input.tex ?? ''))
134  if (parsed.error || parsed.kind !== 'draw') return parsed.error ? parsed : { error: 'Give a formula.' }
135  const raw = Array.isArray(input.notes) ? input.notes : typeof input.notes === 'string' ? [input.notes] : []
136  const notes = raw.map((note) => String(note))
137  if (notes.some((note) => note.trim() === '')) return { error: 'A --note needs a line.' }
138  if (notes.length > 12) return { error: 'Too many notes.' }
139  if (notes.some((note) => note.length > 500)) return { error: 'A note is too long.' }
140  return { kind: 'draw', tex: parsed.tex, legend: input.legend === true, notes }
141}
142
143async function run($, args) {
144  const parsed = parse(args)
145  if (parsed.error) return fail($, parsed.error)
146  if (parsed.kind === 'open') {
147    note = ''
148    return entries.length ? entries.length + ' formula' + (entries.length === 1 ? '' : 's') + '.' : 'No formula yet.'
149  }
150  if (parsed.kind === 'drop') return remove($)
151  return draw($, parsed)
152}
153
154async function draw($, job) {
155  if (job.error) return fail($, job.error)
156  if (!job.tex) return fail($, 'Give a formula.')
157  const made = await $.process.run(['mkdir', '-p', DIR])
158  if (!made || made.exitCode !== 0) return fail($, 'Could not write the PNG.')
159  const stamp = await stampOf($)
160  const out = DIR + '/' + stamp.toString(36) + '-' + Math.random().toString(36).slice(2, 8) + '.png'
161  const argv = drawArgv(out, job)
162  const ran = await runMathcat($, argv.slice(1))
163  if (ran.error) return fail($, ran.error)
164  if (ran.exitCode !== 0) {
165    const detail = String(ran.stderr || ran.stdout || '').trim()
166    return fail($, detail || 'The formula did not render.')
167  }
168  const screened = screenPng(ran.stdout)
169  if (screened.error) return fail($, screened.error)
170  const exists = await $.fs.exists(screened.path)
171  if (!exists) return fail($, 'mathcat did not write a PNG.')
172  const generation = await generationOf($, screened.path)
173  const size = await measure($, screened.path)
174  const entry = {
175    path: screened.path,
176    generation,
177    tex: job.tex,
178    legend: !!job.legend,
179    notes: job.notes || [],
180    width: size ? size.width : 0,
181    height: size ? size.height : 0,
182  }
183  const found = entries.findIndex((item) => same(item, entry))
184  if (found >= 0) {
185    entries[found] = entry
186    index = found
187  } else {
188    entries.push(entry)
189    index = entries.length - 1
190  }
191  note = ''
192  await save($)
193  $.ui.invalidate('ui.render')
194  return 'Drew ' + job.tex
195}
196
197async function runMathcat($, args) {
198  const first = await tryRun($, ['mathcat', ...args])
199  if (first.started) return first
200  let home = ''
201  try {
202    home = (await $.env.get('HOME')) || ''
203  } catch {
204    home = ''
205  }
206  if (home) {
207    const second = await tryRun($, [home.replace(/\/$/, '') + '/.local/bin/mathcat', ...args])
208    if (second.started) return second
209  }
210  return { error: 'mathcat is not on PATH.' }
211}
212
213async function tryRun($, argv) {
214  try {
215    const ran = await $.process.run(argv, { timeoutMs: 60000 })
216    return { started: true, exitCode: ran.exitCode, stdout: ran.stdout, stderr: ran.stderr }
217  } catch {
218    return { started: false }
219  }
220}
221
222async function measure($, path) {
223  try {
224    const file = await $.fs.read(path, { as: 'bytes' })
225    return pngSize(file && file.base64)
226  } catch {
227    return null
228  }
229}
230
231async function stampOf($) {
232  try {
233    const now = await $.clock.now()
234    if (Number.isFinite(now)) return Math.max(0, Math.floor(now))
235  } catch {
236    // Date.now still names a new file.
237  }
238  return Date.now()
239}
240
241async function generationOf($, path) {
242  try {
243    const stat = await $.fs.stat(path)
244    if (stat && Number.isFinite(stat.mtimeMs)) return Math.max(0, Math.floor(stat.mtimeMs))
245  } catch {
246    // A missing stamp still shows the file. Drawing it again bumps the number.
247  }
248  return Date.now()
249}
250
251function same(entry, job) {
252  const notes = Array.isArray(entry.notes) ? entry.notes : []
253  const next = job.notes || []
254  return entry.tex === job.tex && !!entry.legend === !!job.legend && notes.length === next.length && notes.every((line, i) => line === next[i])
255}
256
257async function shift($, by) {
258  if (entries.length === 0) return
259  index = (index + by + entries.length) % entries.length
260  note = ''
261  await save($)
262  $.ui.invalidate('ui.render')
263}
264
265async function remove($) {
266  if (entries.length === 0) return fail($, 'No formula yet.')
267  const gone = entries[index].tex
268  entries.splice(index, 1)
269  if (index >= entries.length) index = 0
270  note = ''
271  await save($)
272  $.ui.invalidate('ui.render')
273  return 'Dropped ' + gone
274}
275
276function fail($, text) {
277  note = text
278  $.ui.invalidate('ui.render')
279  return text
280}
281
hooks/formula.js 152 lines
1// Turn a /mathcat line into one formula, and size the pane box to the PNG.
2// Terminal cells are about twice as tall as they are wide.
3
4const FLAGS = new Set(['--legend', '--note', '--local', '--'])
5
6export function parse(text) {
7  const raw = String(text ?? '').trim()
8  if (raw === '' || raw === 'open') return { kind: 'open' }
9  if (raw === 'drop') return { kind: 'drop' }
10  const tokens = tokenize(raw)
11  if (tokens.error) return { error: tokens.error }
12
13  let legend = false
14  const notes = []
15  const tex = []
16  for (let i = 0; i < tokens.value.length; i++) {
17    const token = tokens.value[i]
18    if (token === '--') {
19      tex.push(...tokens.value.slice(i + 1))
20      break
21    }
22    if (token === '--legend') {
23      legend = true
24      continue
25    }
26    if (token === '--local') continue
27    if (token === '--note') {
28      const note = tokens.value[++i]
29      if (note == null || note.trim() === '') return { error: 'A --note needs a line.' }
30      notes.push(note)
31      continue
32    }
33    if (token.startsWith('--') && FLAGS.has(token) === false && !tex.length) {
34      return { error: 'The pane takes the formula, --legend, and --note.' }
35    }
36    tex.push(token)
37  }
38
39  const formula = unwrap(tex.join(' ').trim())
40  if (!formula) return { error: 'Give a formula.' }
41  if (formula.includes('\0')) return { error: 'Give a formula.' }
42  if (formula.length > 4000) return { error: 'That formula is too long.' }
43  if (notes.length > 12) return { error: 'Too many notes.' }
44  if (notes.some((note) => note.length > 500)) return { error: 'A note is too long.' }
45  return { kind: 'draw', tex: formula, legend, notes }
46}
47
48export function drawArgv(path, job) {
49  const argv = ['mathcat', '--local', '-o', path]
50  if (job.legend) argv.push('--legend')
51  for (const note of job.notes) argv.push('--note', note)
52  argv.push('--', job.tex)
53  return argv
54}
55
56export function cellBox(bodyColumns, width, height) {
57  const columns = Math.min(255, Math.max(1, bodyColumns || 80))
58  if (!width || !height) return { columns, rows: 6 }
59  const rows = Math.max(1, Math.min(255, Math.round((columns * height) / width / 2)))
60  return { columns, rows }
61}
62
63export function pngSize(base64) {
64  if (typeof base64 !== 'string' || base64.length < 32) return null
65  const bytes = decode32(base64.slice(0, 32))
66  if (!bytes || bytes.length < 24) return null
67  if (bytes[0] !== 137 || bytes[1] !== 80 || bytes[2] !== 78 || bytes[3] !== 71) return null
68  const width = u32(bytes, 16)
69  const height = u32(bytes, 20)
70  if (width < 1 || height < 1 || width > 16384 || height > 16384) return null
71  return { width, height }
72}
73
74export function screenPng(text) {
75  const path = String(text ?? '')
76    .split('\n')
77    .map((line) => line.trim())
78    .filter(Boolean)
79    .pop()
80  if (!path || !path.startsWith('/tmp/mathcat-pane/') || !path.toLowerCase().endsWith('.png')) {
81    return { error: 'mathcat did not write a PNG.' }
82  }
83  if (path.includes('\0') || path.split('/').includes('..')) return { error: 'mathcat did not write a PNG.' }
84  return { path }
85}
86
87function unwrap(tex) {
88  if (tex.length >= 3 && tex.startsWith('$') && tex.endsWith('$') && !tex.slice(1, -1).includes('$')) {
89    return tex.slice(1, -1).trim()
90  }
91  return tex
92}
93
94function tokenize(text) {
95  const value = []
96  let current = ''
97  let quote = ''
98  for (let i = 0; i < text.length; i++) {
99    const ch = text[i]
100    if (quote) {
101      if (ch === '\\' && i + 1 < text.length && (text[i + 1] === quote || text[i + 1] === '\\')) {
102        current += text[++i]
103        continue
104      }
105      if (ch === quote) {
106        quote = ''
107        continue
108      }
109      current += ch
110      continue
111    }
112    if (ch === '"' || ch === "'") {
113      quote = ch
114      continue
115    }
116    if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') {
117      if (current) {
118        value.push(current)
119        current = ''
120      }
121      continue
122    }
123    current += ch
124  }
125  if (quote) return { error: 'A quote was not closed.' }
126  if (current) value.push(current)
127  return { value }
128}
129
130function u32(bytes, offset) {
131  return bytes[offset] * 16777216 + bytes[offset + 1] * 65536 + bytes[offset + 2] * 256 + bytes[offset + 3]
132}
133
134function decode32(text) {
135  const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
136  const out = []
137  let buffer = 0
138  let bits = 0
139  for (const ch of text) {
140    if (ch === '=') break
141    const value = alphabet.indexOf(ch)
142    if (value < 0) return null
143    buffer = (buffer << 6) | value
144    bits += 6
145    if (bits >= 8) {
146      bits -= 8
147      out.push((buffer >> bits) & 255)
148    }
149  }
150  return out
151}
152