SLOPSHOPPER

explain-as

/explain: get answers as ASD-STE100 prose, a diagram, an HTML page, or a 3b1b-style video

newpanebandguardcommandtoast
★ 4v0.2.0no licenseupdated 2026-10-04Sumit189/explain-claude-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · explain-as
│ ┃ explain-preview ✕ › fix the failing auth test and add an audit log call │ ┃ No preview yet. │ ⏺ 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 │ │ › /explain │ ⎿ explain-as: explain: picker toggled │ │ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ✦ explain as pick how Claude answers × │ │ │ │ [ ○ Off ] 1: ¶ STE 2: ◇ Diagram 3: ◱ HTML 4: ▶ Video │ │ ▸ Claude answers as usual │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ✦ explain as pick how Claude answers × │ │ │ │ [ ○ Off ] 1: ¶ STE 2: ◇ Diagram 3: ◱ HTML 4: ▶ Video │ │ ▸ Claude answers as usual │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
Pane · explain-preview
No preview yet.
README

explain-as

A Claude Code mod that asks Claude for its output in a format that is easier to understand. It is based on the idea that we will spend more and more time reading LLM output, so the output format matters.

"How a transformer works" in HTML mode: Claude's summary on the left, the interactive page previewed in the pane on the right

FormatWhat Claude does
steWrites about 80% of the way to ASD-STE100 Simplified Technical English: short sentences, active voice, simple words
diagramDraws a Mermaid diagram, rendered as a picture in a pane you can zoom and pan, with short captions
htmlBuilds a self-contained, interactive HTML page: a preview in the pane, and the live page in your browser
videoMakes a 3Blue1Brown-style animated explainer in plain JavaScript (canvas) that opens and plays in Chrome, narrated with your device's text-to-speech (ElevenLabs only if you set ELEVENLABS_API_KEY); a Record button saves it as .webm

Usage

/explain                           # open the picker band above the prompt
/explain html                      # every answer as an HTML page from now on
/explain off                       # back to normal
/explain diagram how git rebase works   # one answer only

In html and diagram mode Claude writes the page (or Mermaid source) to .explain/ in your project. The mod screenshots it with headless Chrome, Chromium or Brave and shows the picture in a pane inside Claude Code (needs a terminal with image support, such as Ghostty, kitty, iTerm2 or WezTerm). In the pane, focus it (click or ctrl+x tab) and use i/o to zoom in and out, h j k l (or the arrow buttons) to scroll, r to reset, and v (● live) to open the real, interactive page in the browser; on macOS that Chrome tab refreshes by itself each time Claude updates the page; zoom uses ffmpeg if it is installed. /explain show reopens the last preview, and the pane links to the full interactive page.

The picker band shows explain as [Off] [STE] [Diagram] [HTML] [Video]. Click a format, or focus the band (ctrl+x tab) and press 0-4. The active format is highlighted, and the status line shows it.

Install

Needs Claude Code 2.1.287 or later (check with claude --version). The desktop app bundles its own Claude Code, so it works there once the app ships 2.1.287+; update the app if /explain does not show up. /explain appears after the session's first message. In Claude Code run:

/plugin marketplace add Sumit189/explain-claude-mod
/plugin install explain-as@explain-as

Then start a new session. To try a local copy without installing: claude --plugin-dir ./explain-claude-mod.

This uses Claude Code function hooks, which are in early access. Run the tests with claude plugin test ./explain-claude-mod.

Source 2 files
hooks/register.tsx 397 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4export const FORMATS: Record<string, string> = {
5  ste:
6    'Write the explanation about 80% of the way to ASD-STE100 Simplified Technical English: ' +
7    'short sentences (max 20 words for steps, 25 for descriptions), one instruction per sentence, ' +
8    'active voice, simple approved words with one meaning each, no idioms or filler. ' +
9    'Keep code, identifiers and technical names exactly as they are.',
10  diagram:
11    'Explain with a diagram first, prose second. Write a Mermaid diagram that shows the real mechanism ' +
12    '(prefer `flowchart TD` or a layout about 3:2 wide; it is shown in a 3:2 pane, so avoid long single rows) ' +
13    '(components, flow, state) and pass it to the mcp__explain-as__show tool with kind "diagram" (not the Write ' +
14    'tool): it is rendered and shown as a picture inside Claude Code. Do not paste the Mermaid source in the reply; ' +
15    'give short captions only.',
16  html:
17    'Deliver the explanation as one self-contained, interactive HTML page (inline CSS and JS, no build step): ' +
18    'clear visual hierarchy, diagrams, and controls or animations where they aid understanding. ' +
19    'Pass it to the mcp__explain-as__show tool with kind "html" (not the Write tool): a screenshot of its ' +
20    'first 1200x800 screen is shown inside Claude Code, so put the key picture there. ' +
21    'Do not paste the HTML in the reply; summarize it in two or three lines.',
22  video:
23    'Make a 3Blue1Brown-style explainer video as one self-contained web page and pass it to the ' +
24    'mcp__explain-as__show tool with kind "video" (not the Write tool); it opens and plays in Chrome. ' +
25    'Draw on a 1920x1080 <canvas> scaled to the window: dark background, smooth eased transitions, shapes and ' +
26    'equations that build up and transform step by step. Split it into timed scenes driven by one clock, with ' +
27    'play/pause, a seek bar and arrow-key scene skipping. Narrate each scene with on-screen captions. ' +
28    'By default use the device\'s own text-to-speech through the browser speechSynthesis API, picking the best ' +
29    'local English voice (prefer names with "Premium", "Enhanced" or "Natural"). Only if the ELEVENLABS_API_KEY ' +
30    'environment variable is set (check with `[ -n "$ELEVENLABS_API_KEY" ]`, never print it), generate one mp3 per ' +
31    'scene into `.explain/<short-name>/` with curl before showing the page and play them in sync instead ' +
32    '(relative paths from `.explain/`); ' +
33    'never put the key in the page. ' +
34    'Add a "Record" button that saves a .webm of the canvas (and the mp3 audio, if any) with MediaRecorder. ' +
35    'No build step, no external libraries. In the reply give the scene list only.',
36}
37
38const LABELS: Record<string, string> = { ste: 'STE', diagram: 'Diagram', html: 'HTML', video: 'Video' }
39const LOOK = {
40  off: { icon: '○', color: 'gray', blurb: 'Claude answers as usual' },
41  ste: { icon: '¶', color: 'cyan', blurb: 'short, plain sentences in Simplified Technical English' },
42  diagram: { icon: '◇', color: 'green', blurb: 'a rendered diagram in a pane, short captions after' },
43  html: { icon: '◱', color: 'magenta', blurb: 'a web page, previewed in a pane' },
44  video: { icon: '▶', color: 'yellow', blurb: 'a 3Blue1Brown-style narrated animation that plays in Chrome' },
45}
46const lookOf = (fmt: string | null) => LOOK[(fmt ?? 'off') as keyof typeof LOOK]
47const HINT = `[${Object.keys(FORMATS).join('|')}|off|show] [topic]`
48const PANE = 'explain-preview'
49const SOURCE = /\/\.explain\/[^/]+\.(html|mmd)$/
50const VIDEO = /\.video\.html$/
51const BROWSERS = [
52  '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
53  '/Applications/Chromium.app/Contents/MacOS/Chromium',
54  '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
55  'google-chrome',
56  'chromium',
57]
58const SIZE = { width: 1200, height: 800 }
59const EXTENSIONS = { diagram: '.mmd', html: '.html', video: '.video.html' }
60
61const mode = atom({ plugin: 'explain-as', key: 'mode' } as const, null)
62const isPickerOpen = atom({ plugin: 'explain-as', key: 'isPickerOpen' } as const, false)
63const preview = atom({ plugin: 'explain-as', key: 'preview' } as const, null)
64const view = atom({ plugin: 'explain-as', key: 'view' } as const, { zoom: 1, x: 0.5, y: 0.5 })
65const SCALE = 2 // screenshots are taken at 2x so a zoomed crop stays sharp
66
67// Crops the screenshot to the zoom and pan in `view`, for the terminal pane.
68async function applyView($: EngineInterface) {
69  const shown = await read($, preview)
70  if (!shown) {
71    return
72  }
73  const { zoom, x, y } = await read($, view)
74  const [fullW, fullH] = [SIZE.width * SCALE, SIZE.height * SCALE]
75  const [w, h] = [Math.round(fullW / zoom), Math.round(fullH / zoom)]
76  const left = Math.round(Math.min(Math.max(x * fullW - w / 2, 0), fullW - w))
77  const top = Math.round(Math.min(Math.max(y * fullH - h / 2, 0), fullH - h))
78  const cropped = `${shown.png}.view.png`
79  // ponytail: needs ffmpeg (sips ignores --cropOffset); without it zoom stays at 100%.
80  const run = zoom === 1
81    ? null
82    : await $.process
83        .run(['ffmpeg', '-y', '-loglevel', 'error', '-i', shown.png, '-vf', `crop=${w}:${h}:${left}:${top}`, cropped])
84        .catch(() => null)
85  if (zoom !== 1 && run?.exitCode !== 0) {
86    $.ui.toast('explain: zoom needs ffmpeg (brew install ffmpeg)')
87  }
88  await update($, preview, () => ({ ...shown, view: run?.exitCode === 0 ? cropped : null, generation: Date.now() }))
89}
90
91async function moveView($: EngineInterface, change: { zoom?: number; dx?: number; dy?: number } | null) {
92  await update($, view, ({ zoom, x, y }) => {
93    if (!change) {
94      return { zoom: 1, x: 0.5, y: 0.5 }
95    }
96    const next = Math.min(8, Math.max(1, zoom * (change.zoom ?? 1)))
97    const edge = 0.5 / next
98    const clamp = (v: number) => Math.min(1 - edge, Math.max(edge, v))
99    return { zoom: next, x: clamp(x + (change.dx ?? 0) / next), y: clamp(y + (change.dy ?? 0) / next) }
100  })
101  await applyView($)
102}
103
104async function setMode($: EngineInterface, fmt: string | null) {
105  await update($, mode, () => fmt)
106  $.ui.status(fmt ? `explain: ${LABELS[fmt]}` : undefined)
107}
108
109function mermaidPage(source: string) {
110  const escaped = source.replaceAll('&', '&amp;').replaceAll('<', '&lt;')
111  return `<!doctype html><html><head><style>
112body{margin:0;height:100vh;display:flex;align-items:center;justify-content:center;background:#fff}
113svg{width:96vw!important;max-width:none!important;max-height:96vh}
114</style></head><body><pre class="mermaid">${escaped}</pre><script type="module">
115import m from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs'
116m.initialize({ startOnLoad: true, theme: 'default' })
117</script></body></html>`
118}
119
120// Screenshots `.explain/*.html` (or a Mermaid `.mmd`, wrapped in a page) and shows it in the pane.
121// ponytail: static screenshot of the first screen; the page's interactivity stays in the browser.
122// Opens a `.video.html` explainer in Chrome so it plays there.
123async function playVideo($: EngineInterface, path: string) {
124  for (const argv of [['open', '-a', 'Google Chrome', path], ['open', path], ['xdg-open', path]]) {
125    try {
126      if ((await $.process.run(argv)).exitCode === 0) {
127        $.ui.toast(`explain: playing ${path.split('/').pop()} in the browser`)
128        return
129      }
130    } catch {
131      // not on this platform, try the next one
132    }
133  }
134  $.ui.toast(`explain: open ${path} in a browser to play it`)
135}
136
137// Refreshes any Chrome tab already showing the page, so "live" stays current as Claude updates it.
138// ponytail: macOS + Chrome only (AppleScript); other browsers need a manual refresh.
139async function refreshLive($: EngineInterface, page: string) {
140  const url = `file://${page}`.replaceAll('"', '\\"')
141  const script = [
142    'if application "Google Chrome" is running then',
143    '  tell application "Google Chrome"',
144    '    repeat with w in windows',
145    `      repeat with t in (tabs of w whose URL is "${url}")`,
146    '        reload t',
147    '      end repeat',
148    '    end repeat',
149    '  end tell',
150    'end if',
151  ].join('\n')
152  await $.process.run(['osascript', '-e', script]).catch(() => null)
153}
154
155async function showPreview($: EngineInterface, path: string) {
156  const page = path.endsWith('.mmd') ? `${path}.html` : path
157  if (page !== path) {
158    await $.fs.write(page, mermaidPage(await $.fs.read(path)))
159  }
160  const png = `${path}.png`
161  const args = [
162    '--headless=new', '--disable-gpu', '--hide-scrollbars', `--window-size=${SIZE.width},${SIZE.height}`,
163    `--force-device-scale-factor=${SCALE}`,
164    '--virtual-time-budget=8000', `--screenshot=${png}`, `file://${page}`,
165  ]
166  for (const browser of BROWSERS) {
167    try {
168      const run = await $.process.run([browser, ...args], { timeoutMs: 60_000 })
169      if (run.exitCode === 0) {
170        // The desktop panel draws the picture inside an Svg, capped at 131072 characters: a small JPEG fits.
171        // ponytail: sips is macOS only; elsewhere the desktop panel shows the link alone.
172        const jpg = `${path}.jpg`
173        const shrunk = await $.process
174          .run(['sips', '-Z', '1000', '-s', 'format', 'jpeg', '-s', 'formatOptions', '55', png, '--out', jpg])
175          .catch(() => null)
176        const isVideo = VIDEO.test(path)
177        await update($, view, () => ({ zoom: 1, x: 0.5, y: 0.5 }))
178        void refreshLive($, page)
179        await update($, preview, () => ({ png, page, jpg: shrunk?.exitCode === 0 ? jpg : null, view: null, isVideo, generation: Date.now() }))
180        const opened = await $.ui.open({ id: PANE, title: `explain · ${path.split('/').pop()}` })
181        if (!opened.isPlaced) {
182          $.ui.toast('explain: preview ready, run /explain show')
183        }
184        return
185      }
186    } catch {
187      // not installed here, try the next one
188    }
189  }
190  $.ui.toast('explain: no Chrome, Chromium or Brave found to render the preview')
191}
192
193export const register: Register = on => {
194  let once: string | undefined // applies to the next prompt only
195
196  on('session.start', async ($, e, next) => {
197    await $.command.register({
198      name: 'explain',
199      description: 'Answer as ASD-STE100 prose, a diagram, an HTML page, or a video',
200      argumentHint: HINT,
201    })
202    // The mod writes the file itself, so showing a picture needs no Write permission.
203    await $.tool.register({
204      name: 'show',
205      description:
206        'Save an explanation into .explain/ in the working directory and show it to the user inside Claude Code: ' +
207        'a Mermaid diagram or HTML page is rendered as a picture in a pane, a video page opens and plays in Chrome. ' +
208        'Pass the whole source each time; calling again with the same name replaces it.',
209      inputSchema: {
210        type: 'object',
211        properties: {
212          name: { type: 'string', description: 'short kebab-case name, e.g. "tcp-handshake"' },
213          kind: { type: 'string', enum: Object.keys(EXTENSIONS) },
214          source: { type: 'string', description: 'the Mermaid text, or the whole self-contained HTML page' },
215        },
216        required: ['name', 'kind', 'source'],
217      },
218    })
219    const fmt = await read($, mode)
220    $.ui.status(fmt ? `explain: ${LABELS[fmt]}` : undefined)
221
222    return next(e)
223  })
224
225  on('command.run', { command: 'explain' }, async ($, e) => {
226    const [fmt = '', ...rest] = e.args.trim().split(/\s+/)
227    const topic = rest.join(' ')
228
229    if (fmt === '') {
230      await update($, isPickerOpen, open => !open)
231      return { text: 'explain: picker toggled' }
232    }
233    if (fmt === 'show') {
234      const shown = await read($, preview)
235      if (!shown) {
236        return { text: 'explain: nothing to show yet' }
237      }
238      await $.ui.open({ id: PANE, title: 'explain preview', focus: true })
239      return { text: 'explain: preview opened' }
240    }
241    if (fmt === 'off') {
242      await setMode($, null)
243      return { text: 'explain: off' }
244    }
245    if (!(fmt in FORMATS)) {
246      return { text: `Usage: /explain ${HINT}` }
247    }
248    if (topic) {
249      once = fmt
250      void $.prompt.submit({ text: topic, asUser: true })
251      return { text: `explain (${fmt}): ${topic}` }
252    }
253    await setMode($, fmt)
254    return { text: `explain: every answer as ${fmt} until /explain off` }
255  })
256
257  on('prompt.submit', async ($, e, next) => {
258    const rule = FORMATS[once ?? (await read($, mode)) ?? '']
259    once = undefined
260    if (!rule) {
261      return next(e)
262    }
263    return next({ ...e, context: [...(e.context ?? []), rule] })
264  })
265
266  on('tool.call', { tool: 'Write' }, async ($, e, next) => {
267    const ran = await next(e)
268    if (VIDEO.test(e.file_path) && ran.deny === undefined && !ran.isError) {
269      await playVideo($, e.file_path)
270    } else if (SOURCE.test(e.file_path) && ran.deny === undefined && !ran.isError) {
271      await showPreview($, e.file_path)
272    }
273    return ran
274  })
275
276  // The cast only quiets tsc: the plugin's own MCP tool is not in the generated tool names until it loads.
277  on('tool.call', { tool: 'mcp__explain-as__show' as 'Bash' }, async ($, e) => {
278    const { name, kind, source } = e as unknown as { name: string; kind: keyof typeof EXTENSIONS; source: string }
279    const slug = String(name).toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '') || 'explain'
280    if (!(kind in EXTENSIONS) || typeof source !== 'string' || !source.trim()) {
281      return { deny: 'show needs kind "diagram", "html" or "video" and a non-empty source.' }
282    }
283    const path = `${await $.session.cwd()}/.explain/${slug}${EXTENSIONS[kind]}`
284    await $.fs.write(path, source)
285    if (kind === 'video') {
286      await showPreview($, path)
287      await playVideo($, path)
288      return { result: `Saved ${path} and opened it in the browser.` }
289    }
290    await showPreview($, path)
291    return { result: `Saved ${path} and showed it to the user in a pane.` }
292  })
293
294  // An edited video is not reopened: reloading the tab already playing it is enough.
295  on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
296    const ran = await next(e)
297    if (SOURCE.test(e.file_path) && !VIDEO.test(e.file_path) && ran.deny === undefined && !ran.isError) {
298      await showPreview($, e.file_path)
299    }
300    return ran
301  })
302
303  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
304    const { Box, Text, Link } = $.ui.resolve(e)
305    const shown = await read($, preview)
306    if (!shown) {
307      return <Text dimColor>No preview yet.</Text>
308    }
309    const link = (
310      <Link href={`file://${shown.page}`} label={shown.isVideo ? '▶ play live in browser' : '● live in browser'} />
311    )
312    if (e.surface !== 'terminal') {
313      const { Svg } = $.ui.resolve(e)
314      const jpeg = shown.jpg ? await $.fs.read(shown.jpg, { as: 'bytes' }).catch(() => null) : null
315      const svg = jpeg
316        ? `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${SIZE.width} ${SIZE.height}">` +
317          `<image href="data:image/jpeg;base64,${jpeg.base64}" width="${SIZE.width}" height="${SIZE.height}"/></svg>`
318        : ''
319      return (
320        <Box flexDirection="column" gap={1}>
321          {svg && svg.length <= 131072 && <Svg source={svg} alt="explain preview" />}
322          {link}
323        </Box>
324      )
325    }
326    const { Button, Image } = $.ui.resolve(e)
327    const { zoom } = await read($, view)
328    // A terminal cell is about twice as tall as it is wide.
329    const columns = Math.min(255, e.props.bodyColumns)
330    const rows = Math.min(255, Math.max(1, e.props.scroll.bodyRows - 4), Math.round((columns * SIZE.height) / SIZE.width / 2))
331    const control = (key: string, label: string, onPress: () => unknown) => (
332      <Button key={key} label={label} hotkey={key} plain onPress={onPress} />
333    )
334    return (
335      <Box flexDirection="column">
336        <Image
337          source={{ file: shown.view ?? shown.png, format: 'png', generation: shown.generation }}
338          columns={columns}
339          rows={rows}
340          alt="explain preview"
341        />
342        <Box gap={2} flexWrap="wrap">
343          {control('i', 'zoom in', () => moveView($, { zoom: 1.5 }))}
344          {control('o', 'zoom out', () => moveView($, { zoom: 1 / 1.5 }))}
345          {control('h', '←', () => moveView($, { dx: -0.25 }))}
346          {control('j', '↓', () => moveView($, { dy: 0.25 }))}
347          {control('k', '↑', () => moveView($, { dy: -0.25 }))}
348          {control('l', '→', () => moveView($, { dx: 0.25 }))}
349          {control('r', 'reset', () => moveView($, null))}
350          <Button key="live" label={shown.isVideo ? '▶ play live' : '● live'} hotkey="v" variant="primary" onPress={() => $.process.run(['open', shown.page])} />
351          <Text dimColor>{Math.round(zoom * 100)}%</Text>
352        </Box>
353      </Box>
354    )
355  })
356
357  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
358    const current = await read($, mode)
359    if (e.props.hasSurvey || !(await read($, isPickerOpen))) {
360      return next(e)
361    }
362
363    const { Box, Button, Text } = $.ui.resolve(e)
364    const choices: [string | null, string][] = [[null, 'Off'], ...Object.entries(LABELS)]
365    const look = lookOf(current)
366
367    return (
368      <Box flexDirection="column" borderStyle="round" borderColor={look.color} paddingX={1}>
369        <Box justifyContent="space-between">
370          <Text>
371            <Text color={look.color} bold>✦ explain as</Text>
372            <Text dimColor>  pick how Claude answers</Text>
373          </Text>
374          <Button key="close" label="×" plain role="dismiss" dimColor onPress={() => update($, isPickerOpen, () => false)} />
375        </Box>
376        <Box gap={2} marginTop={1}>
377          {choices.map(([fmt, label], i) => (
378            <Button
379              key={fmt ?? 'off'}
380              label={`${lookOf(fmt).icon} ${label}`}
381              hotkey={String(i)}
382              plain={fmt === current ? undefined : true}
383              variant={fmt === current ? 'primary' : undefined}
384              dimColor={fmt !== current}
385              onPress={() => setMode($, fmt)}
386            />
387          ))}
388        </Box>
389        <Text>
390          <Text color={look.color}>▸ </Text>
391          <Text italic dimColor>{look.blurb}</Text>
392        </Text>
393      </Box>
394    )
395  })
396}
397
types/index.d.ts 9 lines
1export type Mode = 'ste' | 'diagram' | 'html' | 'video' | null
2export type Preview = { png: string; page: string; jpg: string | null; view: string | null; isVideo: boolean; generation: number } | null
3
4declare module 'claude-code' {
5  interface PluginState {
6    'explain-as': { mode: Mode; isPickerOpen: boolean; preview: Preview; view: { zoom: number; x: number; y: number } }
7  }
8}
9