SLOPSHOPPER

sciplot

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

newpaneguardcommandtoasttool
v0.2.0MITupdated 2026-10-07vcdim/claude-code-sciplot
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · sciplot
│ ┃ sciplot ✕ › fix the failing auth test and add an audit log call │ ┃ No plot yet. Ask Claude to plot something. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /sciplot │ ⎿ sciplot: sciplot pane opened. Ask Claude to plot something; /sci │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · sciplot
No plot yet. Ask Claude to plot something.
README

sciplot

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

Install

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.

Engines

EngineLanguageCost
Pythonmatplotlib, plotly, seaborn (via uv)free
Rbase graphics, ggplot2 (LaTeX via tikzDevice)free
LaTeXTikZ / pgfplotsfree
MATLAB.m code (falls back to GNU Octave)paid
MathematicaWolfram 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.

  • LaTeX text by default when TeX is installed (matplotlib usetex, R tikzDevice, MATLAB's LaTeX interpreter), falling back to plain text if a label breaks it. Pass latex: false to turn it off.
  • 300 dpi PNG/JPG in every engine (plotly: 2× scale); PDF, SVG and EPS stay vector.

Use

/sciplotopen the pane
/sciplot closeclose it
/sciplot export pdf,svg [dir]export the current plot
/sciplot deletemove the current plot to the Trash
/sciplot enginesshow 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.

Develop

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).

License

MIT

Source 3 files
hooks/register.tsx 779 lines
1import { 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}
779
hooks/live.tsx 45 lines
1/* @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}
45
types/index.d.ts 19 lines
1export 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