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

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 183 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// No focus: a focused pane takes the arrows and the hotkey letters away from the prompt.
102// The person clicks the pane (or ctrl+x tab) to use its keys.
103const open = ($: EngineInterface) => $.ui.open({ id: PANE, title: 'Show me', closeOnEscape: true })
104
105export const register: Register = on => {
106  on('session.start', async ($, e, next) => {
107    await $.command.register({ name: 'show-me', description: 'Ask for an answer as mermaid diagrams, or open the diagram pane', argumentHint: '[question]' })
108    return next(e)
109  })
110
111  on('command.run', { command: 'show-me' }, async ($, e) => {
112    const question = e.args.trim()
113    if (question) {
114      // The engine refuses a submit while command.run holds the turn, so it goes out once the command is done.
115      $.clock.after(0, () =>
116        $.prompt.submit({ text: `${question}\n\n${ASK}`, asUser: true }).catch((err: unknown) => $.ui.toast(`show-me: ${String(err)}`)),
117      )
118      return {}
119    }
120    const opened = await open($)
121    return { text: opened.isPlaced ? 'Diagram pane opened.' : `Diagram pane is waiting: ${opened.reason}` }
122  })
123
124  on('turn.complete', async ($, e, next) => {
125    const result = await next(e)
126    const sources = e.agentId || e.reason !== 'answer' ? [] : extractMermaid(e.answer)
127    if (sources.length === 0) return result
128    // Rendering launches a browser; it runs after the turn so the turn ends on time.
129    $.clock.after(0, async () => {
130      const before = await read($, diagrams)
131      const added = sources.map(source => ({ ...cachedDraw(before, source), turnId: e.turnId, title: titleOf(source), source }))
132      const history = await update($, diagrams, list => addTurn(list, added))
133      // The pane jumps to the turn's first diagram.
134      await update($, index, () => Math.max(0, history.length - sources.length))
135      const opened = await open($)
136      if (!opened.isPlaced) $.ui.toast(`show-me: ${sources.length} diagram(s), /show-me to open`)
137      const fresh = [...new Set(added.filter(d => !d.png).map(d => d.source))]
138      if (fresh.length === 0) return
139      const drawn = await render($, fresh, e.turnId)
140      await update($, diagrams, list => replaceTurn(list, e.turnId, drawn))
141    })
142    return result
143  })
144
145  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
146    const { Box, Text, Button } = $.ui.resolve(e)
147    const list = await read($, diagrams)
148    const close = <Button key="close" plain hotkey="x" label="close" onPress={() => $.ui.close({ id: PANE })} />
149    const hint = <Text dimColor>Click the pane or press ctrl+x tab to use its keys</Text>
150    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>
151    const i = Math.min(await read($, index), list.length - 1)
152    const d = list[i]!
153    const turns = [...new Set(list.map(x => x.turnId))]
154    const step = (by: number) => () => update($, index, n => (n + by + list.length) % list.length)
155    const cols = Math.max(1, e.props.bodyColumns)
156    const room = Math.max(1, (e.viewport?.rows ?? 24) - 5)
157
158    let body
159    if (d.error) body = <Box flexDirection="column"><Text color="red">{d.error}</Text><Text dimColor>{d.source}</Text></Box>
160    else if (!d.png || !d.width || !d.height) body = <Text dimColor>rendering…</Text>
161    else if (e.surface === 'terminal') {
162      const { Image } = $.ui.resolve(e as typeof e & { surface: 'terminal' })
163      body = <Image source={{ file: d.png, format: 'png' }} {...fitImage(d.width, d.height, cols, room)} alt={d.title} />
164    } else body = <Text dimColor>{d.source}</Text>
165
166    return (
167      <Box flexDirection="column" paddingTop={1}>
168        <Box flexDirection="row" gap={1}>
169          <Text bold>{`${i + 1}/${list.length}`}</Text>
170          <Text dimColor>{`turn ${turns.indexOf(d.turnId) + 1}/${turns.length}`}</Text>
171          <Text>{d.title}</Text>
172          <Button key="prev" plain hotkey="p" label="‹" onPress={step(-1)} />
173          <Button key="next" plain hotkey="n" label="›" onPress={step(1)} />
174          {d.png && <Button key="open" plain hotkey="o" label="open" onPress={() => void $.process.run(['open', d.png!])} />}
175          {close}
176        </Box>
177        {hint}
178        {body}
179      </Box>
180    )
181  })
182}
183
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