Scientific plots in a side pane: Python, R, TikZ/pgfplots, MATLAB/Octave, Mathematica; export PDF, SVG and more

A Claude Code mod for scientific plots. Ask Claude to plot something; it writes the code, sciplot runs it in the engine you pick and shows the figure in a side pane, ready to export as PDF, SVG or PNG.
<img src="docs/python-contour.png" width="45%" alt="sin(x)·sin(y) contour map, matplotlib"> <img src="docs/mathematica-membrane.png" width="45%" alt="MATLAB logo L-shaped membrane, Mathematica">
<img src="docs/demo.gif" width="90%" alt="plot sin(x) in matplotlib, then re-render it in MATLAB, in a real Claude Code session">
plot sin(x), then the same figure in MATLAB and pgfplots, in a real Claude Code session. Watch the full 30 s demo.
> plot sin(x)·sin(y) as a contour map
> render the MATLAB logo in Mathematica
> make it a 3D surface in plotly
From a Claude Code session:
/plugin install sciplot --marketplace vcdim/claude-code-sciplot
Then /sciplot opens the pane. The picture needs a terminal with the kitty graphics protocol (Ghostty, kitty, WezTerm, iTerm2); elsewhere the pane falls back to text and the exports still work.
| Engine | Language | Cost |
|---|---|---|
| Python | matplotlib, plotly, seaborn (via uv) | free |
| R | base graphics, ggplot2 (LaTeX via tikzDevice) | free |
| LaTeX | TikZ / pgfplots | free |
| MATLAB | .m code (falls back to GNU Octave) | paid |
| Mathematica | Wolfram Language (wolframscript, or the free Wolfram Engine) | paid |
Engines are detected at session start; missing ones are hidden from Claude and fall back to a free option. /sciplot engines shows what was found.
usetex, R tikzDevice, MATLAB's LaTeX interpreter), falling back to plain text if a label breaks it. Pass latex: false to turn it off./sciplot | open the pane |
/sciplot close | close it |
/sciplot export pdf,svg [dir] | export the current plot |
/sciplot delete | move the current plot to the Trash |
/sciplot engines | show which engines are installed |
Pane keys: b / n previous / next plot, p PDF, s SVG, g PNG, d delete; for plotly, i live / static and o open in the browser.
Live plotly. A plotly figure is interactive right in the pane: drag to rotate or pan, scroll to zoom, hover for values. A headless Chrome (your installed Google Chrome, else Playwright's Chromium) draws the figure and the pane forwards the mouse to it.
Each plot is kept in ~/.cache/sciplot/<timestamp>/ with its script and exports, so any figure can be rerun or edited by hand.
git clone https://github.com/vcdim/claude-code-sciplot && cd claude-code-sciplot
./run.sh install # register this folder as a local marketplace and install
./run.sh validate # claude plugin validate
./run.sh test # claude plugin test
./run.sh engines # list engine binaries on PATH
The plugin is a Claude Code hooks module: hooks/register.tsx registers the plot tool, the /sciplot command and the pane; scripts/ holds the per-engine runners (runner.py, runner.R, sciplot_runner.m).
hooks/register.tsx 779 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Plot } from '../types'
5
6const PANE = 'sciplot'
7const TOOL = 'mcp__sciplot__plot'
8const FORMATS = ['png', 'pdf', 'svg', 'eps', 'jpg', 'webp', 'html']
9const PY_PACKAGES = ['matplotlib', 'plotly', 'kaleido', 'pillow', 'numpy', 'pandas']
10const PACKAGE_RE = /^[A-Za-z0-9][A-Za-z0-9_.\-\[\]=<>~!,]*$/
11const LICENSE_RE = /licen[cs]e|activat|not entitled|sign in to mathworks/i
12
13const last = atom({ plugin: 'sciplot', key: 'last' } as const, null as Plot | null)
14const history = atom({ plugin: 'sciplot', key: 'history' } as const, [] as Plot[])
15const busy = atom({ plugin: 'sciplot', key: 'busy' } as const, null as string | null)
16
17// ---------------------------------------------------------------- engines
18
19type Language = 'python' | 'r' | 'tikz' | 'matlab' | 'mathematica'
20
21type Engine = {
22 label: string
23 ext: string
24 paid: boolean
25 formats: string[]
26 howTo: string
27 install: string
28}
29
30const PDF_FORMATS = ['png', 'pdf', 'svg', 'eps', 'jpg']
31
32const ENGINES: Record<Language, Engine> = {
33 python: {
34 label: 'Python (matplotlib / plotly / seaborn)',
35 ext: 'py',
36 paid: false,
37 formats: FORMATS,
38 howTo:
39 'matplotlib: just draw, do not call savefig/show. plotly: assign the figure to `fig`, do not call fig.show(). ' +
40 'Pre-imported: plt, np, pd, px, go. Extra pip packages (seaborn, scipy, ...) via `packages`.',
41 install: 'Install uv: https://docs.astral.sh/uv/',
42 },
43 r: {
44 label: 'R (base graphics / ggplot2)',
45 ext: 'R',
46 paid: false,
47 formats: PDF_FORMATS,
48 howTo:
49 'Base R or ggplot2 (library(ggplot2) works); a ggplot value at top level is printed like the console. ' +
50 'Set size with `sciplot_size <- c(w, h)` in inches. Do not open devices or call ggsave.',
51 install: 'brew install r (free)',
52 },
53 tikz: {
54 label: 'LaTeX TikZ / pgfplots',
55 ext: 'tex',
56 paid: false,
57 formats: PDF_FORMATS,
58 howTo:
59 'Give a tikzpicture environment (pgfplots, amsmath loaded, compat=newest), or a full standalone document.',
60 install: 'Install TeX Live / MacTeX (free): https://tug.org/mactex/',
61 },
62 matlab: {
63 label: 'MATLAB',
64 ext: 'm',
65 paid: true,
66 formats: PDF_FORMATS,
67 howTo: 'Ordinary .m plotting code (plot, hold on, xlabel, ...). Runs headless; do not call exportgraphics/saveas.',
68 install: 'Paid. Free alternative: GNU Octave (brew install octave) runs most .m plotting code; or redo in python.',
69 },
70 mathematica: {
71 label: 'Mathematica / Wolfram Language',
72 ext: 'wl',
73 paid: true,
74 formats: PDF_FORMATS,
75 howTo: 'Wolfram Language code whose last expression (no trailing semicolon) is the graphic.',
76 install: 'Paid. Free alternative: Wolfram Engine for developers (wolfram.com/engine) with wolframscript; or redo in python.',
77 },
78}
79
80type Bins = Record<string, string>
81type Availability = Record<Language, { ok: boolean; via?: string; why?: string }>
82
83let bins: Bins = {}
84let home = ''
85const disabled = new Map<Language, string>()
86
87async function detect($: EngineInterface): Promise<void> {
88 const script = [
89 'export PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:/Library/TeX/texbin:$PATH"',
90 'printf "home=%s\\n" "$HOME"',
91 'for c in uv Rscript pdflatex pdftocairo wolframscript octave-cli; do printf "%s=%s\\n" "$c" "$(command -v $c)"; done',
92 'm=$(command -v matlab || ls -d /Applications/MATLAB_R*.app/bin/matlab 2>/dev/null | sort | tail -1)',
93 'printf "matlab=%s\\n" "$m"',
94 ].join('\n')
95 const { stdout } = await $.process.run(['sh', '-c', script])
96 bins = {}
97 for (const line of stdout.split('\n')) {
98 const at = line.indexOf('=')
99 if (at > 0 && line.slice(at + 1)) bins[line.slice(0, at)] = line.slice(at + 1)
100 }
101 home = bins.home ?? ''
102}
103
104function availability(): Availability {
105 const need = (...names: string[]) => {
106 const missing = names.filter(n => !bins[n])
107 return missing.length ? `missing ${missing.join(', ')}` : undefined
108 }
109 const out = {} as Availability
110 const set = (lang: Language, why: string | undefined, via?: string) => {
111 const off = disabled.get(lang)
112 out[lang] = off ? { ok: false, why: off } : why ? { ok: false, why } : { ok: true, via }
113 }
114 set('python', need('uv'))
115 set('r', need('Rscript', 'pdftocairo'))
116 set('tikz', need('pdflatex', 'pdftocairo'))
117 if (bins.matlab) set('matlab', undefined, 'MATLAB')
118 else set('matlab', bins['octave-cli'] && bins.pdftocairo ? undefined : 'MATLAB not installed', 'Octave')
119 set('mathematica', need('wolframscript', 'pdftocairo'))
120 return out
121}
122
123function toolDescription(av: Availability): string {
124 const langs = Object.keys(ENGINES) as Language[]
125 const on = langs.filter(l => av[l].ok)
126 const off = langs.filter(l => !av[l].ok)
127 return [
128 'Render a scientific figure in the sciplot side pane and optionally export it.',
129 `Available \`language\` values: ${on.map(l => `"${l}"`).join(', ')} (default "python").`,
130 ...on.map(l => `- ${l}: ${ENGINES[l].label}${av[l].via === 'Octave' ? ' via GNU Octave' : ''}. ${ENGINES[l].howTo}`),
131 ...(off.length
132 ? [`Not available here (use python instead): ${off.map(l => `${l} (${av[l].why})`).join('; ')}.`]
133 : []),
134 bins.pdflatex
135 ? 'Text is rendered with LaTeX by default (`latex`: false to turn off): write labels as LaTeX math, e.g. r"$\\sin(x)$" (python) or "$\\sin(x)$" (R, escape backslashes). Escape bare _ % & in text. Falls back to plain text if LaTeX fails.'
136 : 'TeX is not installed, so `latex` is off except in MATLAB, whose built-in latex interpreter still renders "$\\sin(x)$" labels. Python: matplotlib mathtext still renders r"$\\sin(x)$". R: no LaTeX, so write plain labels ("sin(x)") or plotmath (expression(sin(x))), never "$...$".',
137 'The script runs with its output folder as cwd; each call saves the script plus every export there.',
138 '`export` adds formats beyond png: pdf, svg, eps, jpg; python also webp, and html for plotly (interactive).',
139 '`exportTo` copies the exports into that directory as <title>.<fmt>.',
140 ].join('\n')
141}
142
143async function registerTool($: EngineInterface) {
144 await $.tool.register({
145 name: 'plot',
146 description: toolDescription(availability()),
147 inputSchema: {
148 type: 'object',
149 properties: {
150 code: { type: 'string', description: 'Plotting code in the chosen language' },
151 language: { type: 'string', enum: Object.keys(ENGINES), description: 'Default python' },
152 title: { type: 'string', description: 'Short name for the figure' },
153 latex: { type: 'boolean', description: 'LaTeX text rendering (default true when TeX is installed, always for MATLAB): matplotlib usetex, R tikzDevice, MATLAB latex interpreter. Write labels as LaTeX, e.g. "$\\sin(x)$".' },
154 packages: { type: 'array', items: { type: 'string' }, description: 'python only: extra pip packages' },
155 export: { type: 'array', items: { type: 'string', enum: FORMATS }, description: 'Extra formats to save' },
156 exportTo: { type: 'string', description: 'Directory to copy the exports into' },
157 },
158 required: ['code'],
159 },
160 })
161}
162
163// ---------------------------------------------------------------- running
164
165type ProcResult = { exitCode: number; stdout: string; stderr: string }
166type RunOutcome =
167 | { ok: true; kind: string; files: Record<string, string>; size: [number, number] }
168 | { ok: false; error: string }
169
170const tail = (ran: ProcResult, n = 25) =>
171 (ran.stderr.trim() || ran.stdout.trim()).split('\n').slice(-n).join('\n') || `exit code ${ran.exitCode}`
172
173function parseResult(ran: ProcResult): RunOutcome {
174 const line = ran.stdout.split('\n').find(l => l.startsWith('SCIPLOT_RESULT '))
175 if (ran.exitCode !== 0 || !line) return { ok: false, error: tail(ran) }
176 const parsed = JSON.parse(line.slice('SCIPLOT_RESULT '.length))
177 return { ok: true, kind: parsed.kind, files: parsed.files, size: parsed.size }
178}
179
180const mstr = (text: string) => `'${text.replace(/'/g, "''")}'`
181const wstr = (text: string) => `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
182
183function pngSize(base64: string): [number, number] {
184 try {
185 const bin = atob(base64.slice(0, 44))
186 const at = (i: number) =>
187 ((bin.charCodeAt(i) << 24) | (bin.charCodeAt(i + 1) << 16) | (bin.charCodeAt(i + 2) << 8) | bin.charCodeAt(i + 3)) >>> 0
188 return [at(16), at(20)]
189 } catch {
190 return [800, 600]
191 }
192}
193
194// Turn dir/plot.pdf (page 1) into the other formats with pdftocairo.
195async function fromPdf($: EngineInterface, dir: string, formats: string[], kind: string): Promise<RunOutcome> {
196 const pdf = `${dir}/plot.pdf`
197 const files: Record<string, string> = { pdf }
198 const page = ['-f', '1', '-l', '1']
199 for (const fmt of formats) {
200 let argv: string[] | null = null
201 if (fmt === 'png') argv = ['-png', '-r', '300', '-singlefile', ...page, pdf, `${dir}/plot`]
202 if (fmt === 'jpg') argv = ['-jpeg', '-r', '300', '-singlefile', ...page, pdf, `${dir}/plot`]
203 if (fmt === 'svg') argv = ['-svg', ...page, pdf, `${dir}/plot.svg`]
204 if (fmt === 'eps') argv = ['-eps', ...page, pdf, `${dir}/plot.eps`]
205 if (!argv) continue
206 const ran = await $.process.run([bins.pdftocairo!, ...argv])
207 if (ran.exitCode !== 0) return { ok: false, error: `pdftocairo ${fmt}: ${tail(ran)}` }
208 files[fmt] = `${dir}/plot.${fmt}`
209 }
210 let size: [number, number] = [0, 0]
211 if (files.png) size = pngSize(((await $.fs.read(files.png, { as: 'bytes' })) as { base64: string }).base64)
212 return { ok: true, kind, files, size }
213}
214
215async function exists($: EngineInterface, path: string): Promise<boolean> {
216 try {
217 await $.fs.stat(path)
218 return true
219 } catch {
220 return false
221 }
222}
223
224async function runEngine(
225 $: EngineInterface,
226 lang: Language,
227 dir: string,
228 formats: string[],
229 packages: string[],
230 latex = false,
231): Promise<RunOutcome> {
232 const scripts = `${$.plugin.root}/scripts`
233 const tex = latex && !!bins.pdflatex
234 const texEnv: Record<string, string> = tex
235 ? { SCIPLOT_LATEX: '1', SCIPLOT_PDFLATEX: bins.pdflatex!, SCIPLOT_TEXBIN: bins.pdflatex!.replace(/\/[^/]+$/, '') }
236 : {}
237 const long = { timeoutMs: 300_000, env: texEnv }
238
239 if (lang === 'python') {
240 const withs = [...PY_PACKAGES, ...packages].flatMap(p => ['--with', p])
241 const argv = [bins.uv!, 'run', '--no-project', '--quiet', ...withs, 'python', `${scripts}/runner.py`, `${dir}/script.py`, dir, formats.join(',')]
242 return parseResult(await $.process.run(argv, long))
243 }
244
245 if (lang === 'matlab' && bins.matlab) {
246 const call = `addpath(${mstr(scripts)}); sciplot_runner(${mstr(dir)}, ${mstr(formats.join(','))}, ${latex})`
247 return parseResult(await $.process.run([bins.matlab, '-nodisplay', '-nosplash', '-batch', call], long))
248 }
249
250 // The rest draw dir/plot.pdf, then pdftocairo makes the other formats.
251 let ran: ProcResult
252 if (lang === 'matlab') {
253 const code = [
254 "set(0, 'defaultfigurevisible', 'off');",
255 `cd(${mstr(dir)});`,
256 "try, source('script.m'); catch err, disp(err.message); exit(1); end",
257 "if isempty(get(0, 'children')), disp('No figure found: the script must draw something.'); exit(1); end",
258 "set(gcf, 'PaperUnits', 'inches', 'PaperPosition', [0 0 7 4.5], 'PaperSize', [7 4.5]);",
259 "print(gcf, 'plot.pdf', '-dpdf');",
260 ].join(' ')
261 ran = await $.process.run([bins['octave-cli']!, '--no-gui', '--quiet', '--eval', code], long)
262 } else if (lang === 'r') {
263 ran = await $.process.run([bins.Rscript!, `${scripts}/runner.R`, dir], {
264 ...long,
265 env: { ...texEnv, SCIPLOT_RLIB: `${home}/.cache/sciplot/Rlib` },
266 })
267 } else if (lang === 'tikz') {
268 const body = await $.fs.read(`${dir}/script.tex`)
269 const doc = /\\documentclass/.test(body)
270 ? body
271 : [
272 '\\documentclass[border=4pt]{standalone}',
273 '\\usepackage{pgfplots}',
274 '\\pgfplotsset{compat=newest}',
275 '\\usepackage{amsmath}',
276 '\\begin{document}',
277 body,
278 '\\end{document}',
279 ].join('\n')
280 await $.fs.write(`${dir}/plot.tex`, doc)
281 ran = await $.process.run([bins.pdflatex!, '-interaction=nonstopmode', '-halt-on-error', 'plot.tex'], { ...long, cwd: dir })
282 if (ran.exitCode !== 0) {
283 const log = ran.stdout.split('\n')
284 const at = log.findIndex(l => l.startsWith('!'))
285 return { ok: false, error: at >= 0 ? log.slice(at, at + 6).join('\n') : tail(ran) }
286 }
287 } else {
288 const code = `g = Get[${wstr(`${dir}/script.wl`)}]; If[g === Null, Print["No figure: end the script with the graphic, no trailing semicolon."]; Exit[1]]; Export[${wstr(`${dir}/plot.pdf`)}, g]`
289 ran = await $.process.run([bins.wolframscript!, '-code', code], long)
290 }
291 if (ran.exitCode !== 0 || !(await exists($, `${dir}/plot.pdf`))) {
292 return { ok: false, error: tail(ran) }
293 }
294 return fromPdf($, dir, formats, lang === 'matlab' ? 'octave' : lang)
295}
296
297function languageOf(plot: Plot): Language {
298 if (plot.kind === 'matplotlib' || plot.kind === 'plotly') return 'python'
299 if (plot.kind === 'octave') return 'matlab'
300 return plot.kind as Language
301}
302
303// Make the formats a plot does not have yet: from its PDF where it has one, else by running it again.
304async function ensureFormats($: EngineInterface, plot: Plot, formats: string[]): Promise<Plot | string> {
305 const missing = formats.filter(f => !plot.files[f])
306 if (missing.length === 0) return plot
307 const lang = languageOf(plot)
308 if (missing.includes('html') && plot.kind !== 'plotly') return 'html export needs a plotly figure.'
309 const bad = missing.filter(f => !ENGINES[lang].formats.includes(f))
310 if (bad.length) return `${ENGINES[lang].label} cannot export ${bad.join(', ')}.`
311 const ran =
312 lang !== 'python' && plot.kind !== 'matlab' && plot.files.pdf
313 ? await fromPdf($, plot.dir, missing, plot.kind)
314 : await runEngine($, lang, plot.dir, missing, plot.packages, plot.latex ?? false)
315 if (!ran.ok) return ran.error
316 const next = { ...plot, files: { ...plot.files, ...ran.files } }
317 await update($, last, () => next)
318 await update($, history, list => (list ?? []).map(p => (p.id === next.id ? next : p)))
319 return next
320}
321
322// ---------------------------------------------------------------- helpers
323
324function newId(): string {
325 const d = new Date()
326 const p = (n: number) => String(n).padStart(2, '0')
327 const stamp = `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`
328 return `${stamp}-${Math.random().toString(36).slice(2, 6)}`
329}
330
331function slug(text: string): string {
332 return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 60) || 'plot'
333}
334
335function parseFormats(raw: unknown): string[] | string {
336 const list = (Array.isArray(raw) ? raw : String(raw ?? '').split(/[\s,]+/))
337 .map(f => String(f).toLowerCase().replace(/^\./, ''))
338 .filter(Boolean)
339 .map(f => (f === 'jpeg' ? 'jpg' : f))
340 const bad = list.filter(f => !FORMATS.includes(f))
341 return bad.length ? `Unknown format(s): ${bad.join(', ')}. Use ${FORMATS.join(', ')}.` : list
342}
343
344async function copyExports($: EngineInterface, plot: Plot, formats: string[], to: string): Promise<string[]> {
345 const copied: string[] = []
346 if ((await $.process.run(['mkdir', '-p', to])).exitCode !== 0) return copied
347 for (const fmt of formats) {
348 const src = plot.files[fmt]
349 if (!src) continue
350 const dest = `${to.replace(/\/$/, '')}/${slug(plot.title)}.${fmt}`
351 if ((await $.process.run(['cp', src, dest])).exitCode === 0) copied.push(dest)
352 }
353 return copied
354}
355
356// Drop the shown plot from history, show its neighbour, and move its folder to the Trash.
357async function deleteCurrent($: EngineInterface): Promise<string> {
358 const current = await read($, last)
359 if (!current) return 'Nothing to delete.'
360 const list = (await read($, history)) ?? []
361 const at = list.findIndex(p => p.id === current.id)
362 const rest = list.filter(p => p.id !== current.id)
363 await update($, history, () => rest)
364 await update($, last, () => rest[Math.min(Math.max(at, 0), rest.length - 1)] ?? null)
365 const root = `${home}/.cache/sciplot/`
366 if (home && current.dir.startsWith(root) && current.dir.length > root.length) {
367 const trashed = `${home}/.Trash/sciplot-${current.id}`
368 if ((await $.process.run(['mv', current.dir, trashed])).exitCode === 0) {
369 return `Deleted "${current.title}" (moved to ${trashed}).`
370 }
371 }
372 return `Removed "${current.title}" from the pane.`
373}
374
375// Resolves to why the pane is not on screen, or null when it is.
376async function openPane($: EngineInterface): Promise<string | null> {
377 try {
378 const opened = await $.ui.open({ id: PANE, title: 'sciplot', columns: 80 })
379 return opened.isPlaced ? null : opened.reason
380 } catch (err) {
381 return String(err)
382 }
383}
384
385function enginesReport(): string {
386 const av = availability()
387 return (Object.keys(ENGINES) as Language[])
388 .map(l => {
389 const a = av[l]
390 const tag = ENGINES[l].paid ? ' [paid]' : ''
391 return a.ok
392 ? `✓ ${l}: ${ENGINES[l].label}${a.via === 'Octave' ? ' via GNU Octave' : ''}${tag}`
393 : `✗ ${l}: ${a.why}${tag}. ${ENGINES[l].install}`
394 })
395 .join('\n')
396}
397
398// ---------------------------------------------------------------- live plotly view
399
400// A headless browser (scripts/live_view.py) draws a plotly figure's HTML into PNG
401// frames; the pane shows them and hands the pointer back, so the figure rotates,
402// zooms and hovers in place.
403type LiveFrame = { file: string; generation: number; cols: number; rows: number }
404
405const LIVE_KEY = 'live'
406const live = {
407 sock: '',
408 starting: null as Promise<string | null> | null,
409 ready: false,
410 wanted: true, // show plotly figures live; `i` toggles
411 plotId: null as string | null, // the plot shown live
412 region: null as { cols: number; rows: number } | null,
413 pending: null as string | null, // html to open once the pane reports its size
414 frame: null as LiveFrame | null,
415 mounted: null as { cols: number; rows: number } | null,
416 error: null as string | null,
417 watcher: 0,
418}
419
420async function liveCall($: EngineInterface, path: string, body?: unknown): Promise<any> {
421 try {
422 const res = await $.http.fetch(`http://sciplot${path}`, {
423 method: body === undefined ? 'GET' : 'POST',
424 socketPath: live.sock,
425 headers: { 'content-type': 'application/json' },
426 body: body === undefined ? undefined : JSON.stringify(body),
427 })
428 return res.ok ? JSON.parse(res.text || '{}') : null
429 } catch {
430 return null
431 }
432}
433
434// Starts the browser process once per session; resolves to why it could not, or null.
435function startLive($: EngineInterface): Promise<string | null> {
436 if (live.ready) return Promise.resolve(null)
437 if (live.starting) return live.starting
438 live.sock = `${home}/.cache/sciplot/live.sock`
439 const argv = [
440 bins.uv!, 'run', '--no-project', '--quiet', '--with', 'playwright', '--with', 'aiohttp',
441 'python', `${$.plugin.root}/scripts/live_view.py`, live.sock, `${home}/.cache/sciplot/live`,
442 ]
443 live.starting = new Promise(resolve => {
444 void (async () => {
445 let said = false
446 let stderr = ''
447 try {
448 for await (const { stream, text } of $.process.spawn({ argv })) {
449 if (stream === 'stderr') stderr = (stderr + text).slice(-2000)
450 if (!said && stream === 'stdout' && text.includes('"ready"')) {
451 said = true
452 live.ready = true
453 resolve(null)
454 void watchLive($)
455 }
456 }
457 } catch (err) {
458 stderr += String(err)
459 }
460 live.ready = false
461 live.starting = null
462 live.watcher++
463 const why = stderr.trim().split('\n').pop() || 'the live view process exited'
464 if (stderr.trim()) $.ui.log(`sciplot live view: ${stderr.trim()}`, { to: 'debug' })
465 if (!said) resolve(why)
466 })()
467 })
468 return live.starting
469}
470
471// Long-polls the browser for frames and swaps each into the mounted Image.
472async function watchLive($: EngineInterface): Promise<void> {
473 const me = ++live.watcher
474 let version = -1
475 while (live.watcher === me && live.ready) {
476 const s = await liveCall($, `/state?version=${version}`)
477 if (live.watcher !== me) return
478 if (!s) {
479 await $.clock.sleep(250)
480 continue
481 }
482 version = s.version
483 if (s.error && s.error !== live.error) $.ui.toast(`Live view: ${String(s.error).split('\n')[0]}`)
484 live.error = s.error ?? null
485 const f = s.frame as LiveFrame | null
486 if (!live.plotId || !f || f.generation === live.frame?.generation) continue
487 live.frame = f
488 const m = live.mounted
489 if (m && m.cols === f.cols && m.rows === f.rows) {
490 const r = await $.ui.blit({ requestId: PANE, key: LIVE_KEY, source: { file: f.file, format: 'png', generation: f.generation } })
491 if (!r.deny) continue
492 }
493 $.ui.invalidate('ui.render')
494 }
495}
496
497async function goLive($: EngineInterface, plot: Plot): Promise<void> {
498 live.plotId = plot.id
499 live.frame = null
500 const done = await ensureFormats($, plot, ['html'])
501 const why = typeof done === 'string' ? done : await startLive($)
502 if (live.plotId !== plot.id) return
503 if (why || typeof done === 'string') {
504 live.plotId = null
505 live.wanted = false
506 $.ui.toast(`Live view unavailable: ${why}`)
507 $.ui.invalidate('ui.render')
508 return
509 }
510 if (live.region) await liveCall($, '/open', { html: done.files.html, ...live.region })
511 else live.pending = done.files.html!
512}
513
514function stopLive($: EngineInterface): void {
515 if (!live.plotId) return
516 live.plotId = null
517 live.frame = null
518 live.pending = null
519 live.region = null
520 live.mounted = null
521 if (live.ready) void liveCall($, '/close', {})
522}
523
524// ---------------------------------------------------------------- hooks
525
526export const register: Register = on => {
527 on('session.start', async ($, e, next) => {
528 await detect($)
529 await registerTool($)
530 await $.command.register({
531 name: 'sciplot',
532 description: 'Show the sciplot pane; /sciplot engines | delete | close | export pdf,svg [dir]',
533 })
534 return next(e)
535 })
536
537 on('tool.call', { tool: TOOL }, async ($, e) => {
538 const input = e as unknown as {
539 code?: string; language?: string; title?: string; latex?: boolean; packages?: string[]; export?: string[]; exportTo?: string
540 }
541 if (!input.code?.trim()) return { deny: '`code` is required.' }
542 if (!home) await detect($)
543 const lang = (input.language ?? 'python').toLowerCase() as Language
544 if (!ENGINES[lang]) return { deny: `Unknown language "${input.language}". Use ${Object.keys(ENGINES).join(', ')}.` }
545 const av = availability()[lang]
546 if (!av.ok) return { deny: `${ENGINES[lang].label} is not available: ${av.why}. ${ENGINES[lang].install}` }
547 const packages = input.packages ?? []
548 const badPkg = packages.filter(p => !PACKAGE_RE.test(p))
549 if (badPkg.length) return { deny: `Invalid package spec(s): ${badPkg.join(', ')}` }
550 const formats = parseFormats(input.export ?? [])
551 if (typeof formats === 'string') return { deny: formats }
552 const unsupported = formats.filter(f => !ENGINES[lang].formats.includes(f))
553 if (unsupported.length) return { deny: `${ENGINES[lang].label} cannot export ${unsupported.join(', ')}.` }
554
555 // MATLAB's latex interpreter is built in; the others need a TeX install.
556 const latex = input.latex ?? (lang === 'matlab' || !!bins.pdflatex)
557 const id = newId()
558 const title = input.title?.trim() || 'plot'
559 const dir = `${home}/.cache/sciplot/${id}`
560 const script = `${dir}/script.${ENGINES[lang].ext}`
561 await $.fs.write(script, input.code)
562 await update($, busy, () => title)
563 const paneWait = openPane($)
564 let ran: RunOutcome
565 try {
566 ran = await runEngine($, lang, dir, ['png', ...formats.filter(f => f !== 'png')], packages, latex)
567 } catch (err) {
568 ran = { ok: false, error: String(err) }
569 } finally {
570 await update($, busy, () => null)
571 }
572 if (!ran.ok) {
573 if (ENGINES[lang].paid && LICENSE_RE.test(ran.error)) {
574 disabled.set(lang, 'installed but not licensed or activated')
575 await registerTool($)
576 return { result: `${ENGINES[lang].label} is installed but not licensed or activated, so it is off for this session. ${ENGINES[lang].install}\n${ran.error}` }
577 }
578 return { result: `Plot failed:\n${ran.error}\n(script: ${script})` }
579 }
580
581 const plot: Plot = {
582 id, dir, title, kind: ran.kind, png: ran.files.png ?? '',
583 width: ran.size[0], height: ran.size[1], files: ran.files, packages, latex,
584 }
585 await update($, last, () => plot)
586 await update($, history, list => [...(list ?? []), plot].slice(-50))
587 const copied = input.exportTo ? await copyExports($, plot, formats.length ? formats : ['png'], input.exportTo) : []
588 const waiting = await paneWait
589 if (waiting) $.ui.toast(`sciplot pane not shown: ${waiting}. Run /sciplot to open it.`)
590 const lines = [
591 waiting
592 ? `Rendered ${plot.kind} figure "${title}" (${plot.width}x${plot.height}px), but the pane is not shown: ${waiting}. The user can run /sciplot to open it.`
593 : `Rendered ${plot.kind} figure "${title}" (${plot.width}x${plot.height}px) in the sciplot pane.`,
594 ...Object.entries(plot.files).map(([fmt, path]) => `${fmt}: ${path}`),
595 ...(copied.length ? ['Copied:', ...copied] : []),
596 ]
597 return { result: lines.join('\n') }
598 })
599
600 on('command.run', { command: 'sciplot' }, async ($, e) => {
601 const args = String((e as unknown as { args?: string }).args ?? '').trim().split(/\s+/).filter(Boolean)
602 if (args[0] === 'engines') {
603 await detect($)
604 await registerTool($)
605 return { text: enginesReport() }
606 }
607 if (args[0] === 'delete') {
608 if (!home) await detect($)
609 return { text: await deleteCurrent($) }
610 }
611 if (args[0] === 'close') {
612 await $.ui.close({ id: PANE })
613 return { text: 'sciplot pane closed. /sciplot reopens it.' }
614 }
615 if (args[0] !== 'export') {
616 const waiting = await openPane($)
617 if (waiting) return { text: `sciplot pane could not be placed: ${waiting}` }
618 return { text: 'sciplot pane opened. Ask Claude to plot something; /sciplot engines; /sciplot export pdf,svg [dir]; /sciplot close.' }
619 }
620 const plot = await read($, last)
621 if (!plot) return { text: 'Nothing plotted yet.' }
622 const formats = parseFormats(args[1] ?? 'pdf')
623 if (typeof formats === 'string') return { text: formats }
624 const done = await ensureFormats($, plot, formats)
625 if (typeof done === 'string') return { text: `Export failed:\n${done}` }
626 const copied = args[2] ? await copyExports($, done, formats, args[2]) : formats.map(f => done.files[f])
627 return { text: `Exported:\n${copied.join('\n')}` }
628 })
629
630 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
631 const { Box, Text, Button, Image, Client } = $.ui.resolve(e) as ReturnType<typeof $.ui.resolve> & { Image?: any; Client?: any }
632 const plot = await read($, last)
633 const working = await read($, busy)
634 const plots = (await read($, history)) ?? []
635 const props = e.props as { bodyColumns?: number; placement?: 'dock' | 'inline'; scroll?: { bodyRows: number } }
636 const body = props.bodyColumns ?? e.viewport?.columns ?? 80
637 // Docked, the pane fills its height and the toolbar sits on the bottom row(s).
638 const docked = props.placement === 'dock' && !!props.scroll?.bodyRows
639 const barRows = body < 64 ? 2 : 1
640 const maxRows = docked
641 ? Math.max(4, props.scroll!.bodyRows - 1 - barRows - 1)
642 : Math.max(6, (e.viewport?.rows ?? 40) - 6)
643
644 if (!plot) {
645 return (
646 <Box flexDirection="column">
647 <Text dimColor>{working ? `Rendering ${working}…` : 'No plot yet. Ask Claude to plot something.'}</Text>
648 </Box>
649 )
650 }
651
652 let png: string | null = null
653 try {
654 png = ((await $.fs.read(plot.png, { as: 'bytes' })) as { base64: string }).base64
655 } catch {
656 png = null
657 }
658
659 // Terminal cells are about 2.5x as tall as wide (Ghostty/kitty default fonts); 2 left a gap above and below.
660 const cell = 2.5
661 let columns = Math.max(10, Math.min(255, body - 1))
662 let rows = Math.max(4, Math.round((columns * plot.height) / Math.max(1, plot.width) / cell))
663 if (rows > maxRows) {
664 rows = maxRows
665 columns = Math.max(10, Math.min(columns, Math.round((rows * cell * plot.width) / Math.max(1, plot.height))))
666 }
667
668 // Plotly figures go live: a headless browser draws them and takes the pointer.
669 if (live.plotId && live.plotId !== plot.id) stopLive($)
670 const canLive = plot.kind === 'plotly' && !!Image && !!Client && e.surface === 'terminal'
671 if (canLive && live.wanted && !live.plotId) void goLive($, plot)
672 const isLive = canLive && live.plotId === plot.id
673 if (isLive) {
674 columns = Math.max(10, Math.min(255, body - 1))
675 rows = maxRows
676 live.mounted = live.frame ? { cols: columns, rows } : null
677 }
678
679 const index = plots.findIndex(p => p.id === plot.id)
680 const step = async (delta: number) => {
681 const list = (await read($, history)) ?? []
682 const current = await read($, last)
683 const target = list[list.findIndex(p => p.id === current?.id) + delta]
684 if (target) await update($, last, () => target)
685 }
686
687 const exportAndReveal = async (fmt: string) => {
688 const current = await read($, last)
689 if (!current) return
690 const done = await ensureFormats($, current, [fmt])
691 if (typeof done === 'string') return void $.ui.toast(`Export failed: ${done.split('\n').pop()}`)
692 await $.process.run(['open', '-R', done.files[fmt]!])
693 $.ui.toast(`Saved ${done.files[fmt]}`)
694 }
695
696 return (
697 <Box flexDirection="column">
698 <Text bold>
699 {plot.title} <Text dimColor>{index >= 0 ? `${index + 1}/${plots.length} · ` : ''}{plot.kind}{working ? ` · rendering ${working}…` : ''}</Text>
700 </Text>
701 {isLive ? (
702 <Box flexDirection="column" width={columns} height={rows}>
703 {live.frame ? (
704 <Image key={LIVE_KEY} source={{ file: live.frame.file, format: 'png', generation: live.frame.generation }} columns={columns} rows={rows} alt={plot.title} />
705 ) : (
706 <Text dimColor>Starting the live view…</Text>
707 )}
708 <Box position="absolute" top={0} left={0} width={columns} height={rows}>
709 <Client key={`live-${plot.id}`} module="./live.tsx" width={columns} height={rows} />
710 </Box>
711 </Box>
712 ) : Image && png ? (
713 // Keep it a direct child of an unsized column: wrapped or in a fixed-height pane it drew clipped or not at all.
714 <Image key={`${plot.id}-${columns}x${rows}`} source={{ png }} columns={columns} rows={rows} alt={plot.title} />
715 ) : (
716 <Text dimColor>{plot.png}</Text>
717 )}
718 {/* Docked, a fixed-height spacer (not flexGrow in a sized column) pushes the toolbar to the bottom. */}
719 {docked && (isLive || (Image && png)) && <Box height={Math.max(0, props.scroll!.bodyRows - 1 - rows - barRows)} />}
720 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
721 <Button hotkey="b" onPress={() => step(-1)} dimColor={index <= 0}>←</Button>
722 <Button hotkey="n" onPress={() => step(1)} dimColor={index < 0 || index >= plots.length - 1}>→</Button>
723 <Button hotkey="p" onPress={() => exportAndReveal('pdf')}>PDF</Button>
724 <Button hotkey="s" onPress={() => exportAndReveal('svg')}>SVG</Button>
725 <Button hotkey="g" onPress={() => exportAndReveal('png')}>PNG</Button>
726 <Button hotkey="d" dimColor onPress={async () => $.ui.toast(await deleteCurrent($))}>Delete</Button>
727 {canLive && (
728 <Button hotkey="i" onPress={async () => {
729 live.wanted = !isLive
730 if (isLive) stopLive($)
731 $.ui.invalidate('ui.render')
732 }}>{isLive ? 'Static' : 'Live'}</Button>
733 )}
734 {plot.kind === 'plotly' && (
735 <Button hotkey="o" onPress={async () => {
736 const done = await ensureFormats($, plot, ['html'])
737 if (typeof done !== 'string') await $.process.run(['open', done.files.html!])
738 }}>Browser</Button>
739 )}
740 </Box>
741 </Box>
742 )
743 })
744
745 // From live.tsx: the region's size, and pointer events to forward.
746 on('ui.message', async ($, e, next) => {
747 if (e.requestId !== PANE || !live.plotId) return next(e)
748 const data = e.data as { type?: string; cols?: number; rows?: number; events?: unknown[] }
749 if (data.type === 'size' && data.cols && data.rows) {
750 live.region = { cols: data.cols, rows: data.rows }
751 if (live.pending) {
752 const html = live.pending
753 live.pending = null
754 await liveCall($, '/open', { html, ...live.region })
755 } else {
756 await liveCall($, '/size', live.region)
757 }
758 } else if (data.type === 'input' && Array.isArray(data.events)) {
759 await liveCall($, '/input', { events: data.events })
760 }
761 return next(e)
762 })
763
764 // The wheel over a live figure zooms it rather than scrolling the pane.
765 on('ui.scroll', { requestId: PANE }, async ($, e, next) => {
766 if (!live.plotId || !live.ready || e.by === 0) return next(e)
767 const x = (e.pointer?.column ?? Math.floor((live.region?.cols ?? 2) / 2)) + 0.5
768 const y = (e.pointer?.row ?? Math.floor((live.region?.rows ?? 2) / 2)) - 1 + 0.5 // the title row sits above
769 await liveCall($, '/input', { events: [{ kind: 'wheel', x, y, dy: Math.max(-10, Math.min(10, e.by)) }] })
770 return {}
771 })
772
773 on('ui.close', { id: PANE }, async ($, e, next) => {
774 const closed = await next(e)
775 stopLive($)
776 return closed
777 })
778}
779hooks/live.tsx 45 lines1/* @jsx h */
2import type { ClientPointerEvent, ClientSurface } from 'claude-code'
3
4// Runs in the terminal over the live plotly picture: reports the region's size
5// and forwards the pointer (in cells, sub-cell where the terminal says) to the
6// hooks module, which hands it to the headless browser.
7
8type State = { cols: number; rows: number }
9type LiveEvent = { kind: 'down' | 'move' | 'up'; button?: string; x: number; y: number }
10
11function toEvent(e: ClientPointerEvent): LiveEvent | null {
12 if (e.type === 'enter' || e.type === 'leave') return null
13 const ev: LiveEvent = { kind: e.type, x: e.fine?.x ?? e.x + 0.5, y: e.fine?.y ?? e.y + 0.5 }
14 if (e.button) ev.button = e.button
15 return ev
16}
17
18export default function Live(_props: unknown, surface: ClientSurface<State>) {
19 const { Box } = surface.elements
20
21 if (surface.state === undefined) {
22 surface.setState({ cols: 0, rows: 0 })
23 const queue: LiveEvent[] = []
24 surface.onPointer(e => {
25 const ev = toEvent(e)
26 if (!ev) return
27 // Keep only the latest of a run of moves: the browser needs where the pointer is, not every step.
28 const prev = queue[queue.length - 1]
29 if (ev.kind === 'move' && prev?.kind === 'move' && prev.button === ev.button) queue[queue.length - 1] = ev
30 else queue.push(ev)
31 })
32 surface.every(30, () => {
33 if (queue.length) surface.post({ type: 'input', events: queue.splice(0, queue.length) })
34 })
35 }
36
37 const { columns: cols, rows } = surface
38 if (cols > 0 && rows > 0 && surface.state && (surface.state.cols !== cols || surface.state.rows !== rows)) {
39 surface.setState({ cols, rows })
40 surface.post({ type: 'size', cols, rows })
41 }
42
43 return <Box flexDirection="column" height="100%" />
44}
45types/index.d.ts 19 lines1export type Plot = {
2 id: string
3 dir: string
4 title: string
5 kind: string
6 png: string
7 width: number
8 height: number
9 files: Record<string, string>
10 packages: string[]
11 latex?: boolean
12}
13
14declare module 'claude-code' {
15 interface PluginState {
16 sciplot: { last: Plot | null; history: Plot[]; busy: string | null }
17 }
18}
19