SLOPSHOPPER

show-me

Draws the mermaid diagrams of each answer as images in a pane (/show-me); needs mmdc and a kitty-graphics terminal such as Ghostty

newpanecommandtoastprocesstimer
★ 5v0.4.6MITupdated 2026-10-07arasovic/claude-code-mods/show-me
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · show-me
│ ┃ Show me ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ No diagrams yet. Ask with /show-me. x: close ⏺ Read(src/auth.ts) │ ┃ Click the pane or press ctrl+x tab to use ⎿ Read 6 lines │ ┃ its keys ⏺ 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 │ │ › /show-me │ ⎿ show-me: Diagram pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Show me
No diagrams yet. Ask with /show-me. x: close Click the pane or press ctrl+x tab to use its keys
README

show-me

A pane that draws the mermaid diagrams of each answer as images.

<img src="../docs/show-me.png" alt="The show-me pane drawing a flowchart from an answer, with keys to step through, open and close" width="780">

What it does

  • Any answer: when an answer holds one or more `mermaid fences, the pane opens and draws each one as a picture once the turn ends.
  • /show-me <question>: sends the question with a request to answer in mermaid diagrams.
  • History: the pane keeps the last 30 diagrams across turns. A new answer jumps to its first diagram; p and n step back into earlier turns. The header shows which turn a diagram came from. A diagram whose source is already in the history shows at once, without a new render.
  • /show-me: opens the pane again with the history.
  • Keys: the pane never takes the keyboard by itself, so typing and Claude Code's own keys keep working. Click the pane or press ctrl+x tab, then p and n step through the diagrams, o opens the PNG in the system viewer, and x or Esc closes the pane.

If rendering fails, the pane shows the first error line and the diagram's source. If mmdc is not installed, a toast and the pane give the install command.

Requirements

  • mmdc on PATH. On macOS with Google Chrome installed, skip puppeteer's browser download; the mod points mmdc at the installed Chrome:
  PUPPETEER_SKIP_DOWNLOAD=1 npm i -g @mermaid-js/mermaid-cli

Elsewhere, install it normally so puppeteer brings its own browser.

  • A terminal with the kitty graphics protocol, such as Ghostty or kitty. Other terminals show the diagram's title in place of the picture.
  • The Claude desktop app does not need kitty: the pane shows the same picture there. Until the picture is ready, it shows the mermaid source.

Install

claude plugin marketplace add arasovic/claude-code-mods
claude plugin install show-me@claude-code-mods

Restart Claude Code and type /show-me how does this request flow through the app.

Notes

  • The pane opens by itself only when the terminal is at least 144 columns wide. Below that a toast says how many diagrams are ready; /show-me opens the pane.
  • Images are written under $TMPDIR/show-me/, one folder per turn. Each render deletes the turn folders older than a day, except the ones the history still shows.
  • Subagent answers are ignored; only the main conversation's diagrams are drawn.

Develop

claude plugin validate .
claude plugin test .
../typecheck.sh show-me
Source 2 files
hooks/register.tsx 198 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Diagram } from '../types'
5
6const PANE = 'show-me'
7const CHROME = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
8const ASK = 'Answer with one or more mermaid diagrams in ```mermaid fences (prefer flowchart LR), each followed by a short explanation.'
9
10const diagrams = atom({ plugin: 'show-me', key: 'diagrams' } as const, [] as Diagram[])
11const index = atom({ plugin: 'show-me', key: 'index' } as const, 0)
12
13export const extractMermaid = (text: string) => [...text.matchAll(/^```mermaid[^\n]*\n([\s\S]*?)^```/gm)].map(m => m[1]!.trim()).filter(Boolean)
14
15const titleOf = (source: string) => source.split('\n').find(l => l.trim() && !l.trim().startsWith('%%'))?.trim() ?? 'diagram'
16
17const PNG_SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10]
18
19// Width and height from the PNG's IHDR chunk: bytes 16-23, big-endian. Anything that is not a PNG gives undefined.
20export const pngSize = (base64: string) => {
21  const bytes = Uint8Array.from(atob(base64.slice(0, 32)), c => c.charCodeAt(0))
22  if (bytes.length < 24 || PNG_SIGNATURE.some((b, i) => bytes[i] !== b)) return undefined
23  const v = new DataView(bytes.buffer)
24  return { width: v.getUint32(16), height: v.getUint32(20) }
25}
26
27// A terminal cell is about twice as tall as it is wide.
28export const fitImage = (width: number, height: number, maxCols: number, maxRows: number) => {
29  const clamp = (n: number, hi: number) => Math.max(1, Math.min(hi, 255, Math.round(n)))
30  let columns = maxCols
31  let rows = (columns * height) / width / 2
32  if (rows > maxRows) {
33    rows = maxRows
34    columns = (rows * 2 * width) / height
35  }
36  return { columns: clamp(columns, maxCols), rows: clamp(rows, maxRows) }
37}
38
39const DAY = 24 * 60 * 60 * 1000
40// ponytail: fixed history cap; make it a userConfig option if someone needs more.
41const MAX_DIAGRAMS = 30
42
43// A new turn's diagrams go after the history; the oldest drop past the cap.
44export const addTurn = (list: Diagram[], added: Diagram[], max = MAX_DIAGRAMS) => [...list, ...added].slice(-max)
45
46// A turn's rendered diagrams replace its placeholders by source, so one the cap dropped mid-render shifts nothing.
47export const replaceTurn = (list: Diagram[], turnId: string, drawn: Diagram[]) =>
48  list.map(d => (d.turnId === turnId ? (drawn.find(x => x.source === d.source) ?? d) : d))
49
50// A source the history already drew reuses that PNG; only new sources go to mmdc.
51export const cachedDraw = (list: Diagram[], source: string) => list.findLast(d => d.source === source && d.png && !d.error)
52
53// The turn folders the history still points at: its own turns and the folders its cached PNGs live in.
54export const keptFolders = (list: Diagram[]) => new Set(list.flatMap(d => [d.turnId, ...(d.png ? [d.png.split('/').slice(-2)[0]!] : [])]))
55
56// Other sessions share this folder, so only turns older than a day go; a newer one may still be on screen.
57// The history's own folders stay whatever their age.
58const sweep = async ($: EngineInterface, root: string, keep: Set<string>) => {
59  const entries = await $.fs.list(root).catch(() => [])
60  const old = []
61  for (const entry of entries) {
62    if (entry.kind !== 'dir' || keep.has(entry.name)) continue
63    const stat = await $.fs.stat(`${root}/${entry.name}`).catch(() => null)
64    if (stat && Date.now() - stat.mtimeMs > DAY) old.push(`${root}/${entry.name}`)
65  }
66  if (old.length) await $.process.run(['rm', '-rf', ...old]).catch(() => {})
67}
68
69const render = async ($: EngineInterface, sources: string[], turnId: string) => {
70  const root = `${((await $.env.get('TMPDIR')) ?? '/tmp/').replace(/\/?$/, '/')}show-me`
71  await sweep($, root, keptFolders(await read($, diagrams)))
72  const dir = `${root}/${turnId}`
73  // mmdc renders every fence of a markdown file in one browser launch, as out-1.png, out-2.png, ...
74  await $.fs.write(`${dir}/in.md`, sources.map(s => '```mermaid\n' + s + '\n```').join('\n\n'))
75  // Use the installed Chrome when there is one, so mmdc needs no browser download of its own.
76  const hasChrome = await $.fs.exists(CHROME)
77  if (hasChrome) await $.fs.write(`${dir}/puppeteer.json`, JSON.stringify({ executablePath: CHROME, headless: 'shell' }))
78  const run = await $.process
79    .run(['mmdc', ...(hasChrome ? ['-p', `${dir}/puppeteer.json`] : []), '-i', `${dir}/in.md`, '-o', `${dir}/out.md`, '-e', 'png', '-t', 'dark', '-b', 'transparent', '-s', '2'], { timeoutMs: 120_000 })
80    .catch(async (err: unknown) => {
81      // A rejection means mmdc could not start or ran past the timeout; only a missing mmdc gets the install hint.
82      const found = await $.process.run(['sh', '-c', 'command -v mmdc']).then(r => r.exitCode === 0, () => true)
83      if (found) return { exitCode: 1, stderr: String(err) }
84      const hint = `mmdc is not installed: ${hasChrome ? 'PUPPETEER_SKIP_DOWNLOAD=1 ' : ''}npm i -g @mermaid-js/mermaid-cli`
85      $.ui.toast(`show-me: ${hint}`)
86      return { exitCode: 127, stderr: hint }
87    })
88  const error = run.exitCode === 0 ? undefined : run.stderr.trim().split('\n')[0] || `mmdc exited ${run.exitCode}`
89  return Promise.all(
90    sources.map(async (source, i): Promise<Diagram> => {
91      const title = titleOf(source)
92      if (error) return { turnId, title, source, error }
93      const png = `${dir}/out-${i + 1}.png`
94      const bytes = await $.fs.read(png, { as: 'bytes' }).catch(() => null)
95      const size = bytes && pngSize(bytes.base64)
96      return size ? { turnId, title, source, png, ...size } : { turnId, title, source, error: `mmdc wrote no valid PNG at ${png}` }
97    }),
98  )
99}
100
101// Other surfaces have no Image, so the PNG goes inside an Svg, at half its pixels since mmdc draws at scale 2.
102// Undefined while there is no PNG, or past the Svg's 131072-character cap.
103export const svgOf = (d: Diagram, base64: string) => {
104  if (!d.width || !d.height) return undefined
105  const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${d.width / 2}" height="${d.height / 2}" viewBox="0 0 ${d.width} ${d.height}"><image href="data:image/png;base64,${base64}" width="${d.width}" height="${d.height}"/></svg>`
106  return svg.length <= 131072 ? svg : undefined
107}
108
109// No focus: a focused pane takes the arrows and the hotkey letters away from the prompt.
110// The person clicks the pane (or ctrl+x tab) to use its keys.
111const open = ($: EngineInterface) => $.ui.open({ id: PANE, title: 'Show me', closeOnEscape: true })
112
113export const register: Register = on => {
114  on('session.start', async ($, e, next) => {
115    await $.command.register({ name: 'show-me', description: 'Ask for an answer as mermaid diagrams, or open the diagram pane', argumentHint: '[question]' })
116    return next(e)
117  })
118
119  on('command.run', { command: 'show-me' }, async ($, e) => {
120    const question = e.args.trim()
121    if (question) {
122      // The engine refuses a submit while command.run holds the turn, so it goes out once the command is done.
123      $.clock.after(0, () =>
124        $.prompt.submit({ text: `${question}\n\n${ASK}`, asUser: true }).catch((err: unknown) => $.ui.toast(`show-me: ${String(err)}`)),
125      )
126      return {}
127    }
128    const opened = await open($)
129    return { text: opened.isPlaced ? 'Diagram pane opened.' : `Diagram pane is waiting: ${opened.reason}` }
130  })
131
132  on('turn.complete', async ($, e, next) => {
133    const result = await next(e)
134    const sources = e.agentId || e.reason !== 'answer' ? [] : extractMermaid(e.answer)
135    if (sources.length === 0) return result
136    // Rendering launches a browser; it runs after the turn so the turn ends on time.
137    $.clock.after(0, async () => {
138      const before = await read($, diagrams)
139      const added = sources.map(source => ({ ...cachedDraw(before, source), turnId: e.turnId, title: titleOf(source), source }))
140      const history = await update($, diagrams, list => addTurn(list, added))
141      // The pane jumps to the turn's first diagram.
142      await update($, index, () => Math.max(0, history.length - sources.length))
143      const opened = await open($)
144      if (!opened.isPlaced) $.ui.toast(`show-me: ${sources.length} diagram(s), /show-me to open`)
145      const fresh = [...new Set(added.filter(d => !d.png).map(d => d.source))]
146      if (fresh.length === 0) return
147      const drawn = await render($, fresh, e.turnId)
148      await update($, diagrams, list => replaceTurn(list, e.turnId, drawn))
149    })
150    return result
151  })
152
153  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
154    const { Box, Text, Button, Markdown } = $.ui.resolve(e)
155    const list = await read($, diagrams)
156    const close = <Button key="close" plain hotkey="x" label="close" onPress={() => $.ui.close({ id: PANE })} />
157    const isTerminal = e.surface === 'terminal'
158    const hint = isTerminal && <Text dimColor>Click the pane or press ctrl+x tab to use its keys</Text>
159    if (list.length === 0) return <Box flexDirection="column" paddingTop={1}><Box gap={1}><Text dimColor>No diagrams yet. Ask with /show-me.</Text>{close}</Box>{hint}</Box>
160    const i = Math.min(await read($, index), list.length - 1)
161    const d = list[i]!
162    const turns = [...new Set(list.map(x => x.turnId))]
163    const step = (by: number) => () => update($, index, n => (n + by + list.length) % list.length)
164    const cols = Math.max(1, e.props.bodyColumns)
165    const room = Math.max(1, (e.viewport?.rows ?? 24) - 5)
166
167    let body
168    if (!isTerminal) {
169      const bytes = d.png && !d.error ? await $.fs.read(d.png, { as: 'bytes' }).catch(() => null) : null
170      const svg = bytes && svgOf(d, bytes.base64)
171      const { Svg } = $.ui.resolve(e as typeof e & { surface: 'desktop' })
172      // Without a picture the source goes out as a mermaid fence, which a surface may draw itself.
173      body = svg ? <Svg source={svg} alt={d.title} /> : <Markdown text={'```mermaid\n' + d.source + '\n```'} />
174    } else if (d.error) body = <Box flexDirection="column"><Text color="red">{d.error}</Text><Text dimColor>{d.source}</Text></Box>
175    else if (!d.png || !d.width || !d.height) body = <Text dimColor>rendering…</Text>
176    else {
177      const { Image } = $.ui.resolve(e as typeof e & { surface: 'terminal' })
178      body = <Image source={{ file: d.png, format: 'png' }} {...fitImage(d.width, d.height, cols, room)} alt={d.title} />
179    }
180
181    return (
182      <Box flexDirection="column" paddingTop={1}>
183        <Box flexDirection="row" gap={1}>
184          <Text bold>{`${i + 1}/${list.length}`}</Text>
185          <Text dimColor>{`turn ${turns.indexOf(d.turnId) + 1}/${turns.length}`}</Text>
186          <Text>{d.title}</Text>
187          <Button key="prev" plain hotkey="p" label="‹" onPress={step(-1)} />
188          <Button key="next" plain hotkey="n" label="›" onPress={step(1)} />
189          {d.png && <Button key="open" plain hotkey="o" label="open" onPress={() => void $.process.run(['open', d.png!])} />}
190          {close}
191        </Box>
192        {hint}
193        {body}
194      </Box>
195    )
196  })
197}
198
types/index.d.ts 11 lines
1export type Diagram = { turnId: string; title: string; source: string; png?: string; width?: number; height?: number; error?: string }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'show-me': {
6      diagrams: Diagram[]
7      index: number
8    }
9  }
10}
11