SLOPSHOPPER

dgmo-pane

Renders each .dgmo file Claude writes or edits in a live side pane, and tells Claude the parser's errors

newpanebandguardcommandprompt
v0.3.0no licenseupdated 2026-10-07diagrammo/dgmo-pane
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · dgmo-pane
│ ┃ dgmo ✕ › fix the failing auth test and add an audit log call │ ┃ Nothing drawn yet. Ask Claude to diagram │ ┃ something, or run /dgmo-pane <file or ⏺ Read(src/auth.ts) │ ┃ description>. ⎿ 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 │ │ › /dgmo-pane │ ⎿ dgmo-pane: dgmo pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · dgmo
Nothing drawn yet. Ask Claude to diagram something, or run /dgmo-pane <file or description>.
README

dgmo-pane

Ask Claude for a diagram and see it beside the conversation.

A Claude Code mod that adds a live diagram pane. Say "diagram the login flow" or "draw this directory structure", and Claude writes the diagram in DGMO and the picture appears in a side pane. When Claude gets the syntax wrong, the error goes back to Claude, which fixes it and tries again.

How dgmo-pane works: you ask, Claude writes DGMO, the show_diagram tool checks it, errors go back to Claude, and the picture appears in the pane

<sub>That picture is itself a DGMO diagram: assets/how-it-works.dgmo.</sub>

Install

You need Claude Code 2.1.282 or later. Check with claude --version.

Inside a Claude Code session in your terminal, run:

/plugin install dgmo-pane --marketplace diagrammo/dgmo-pane

If it asks to add the diagrammo/dgmo-pane marketplace, answer y. If it asks for a scope, choose user to have the pane in every project.

Restart Claude Code (quit and run claude again). The /dgmo-pane command and the show_diagram tool are then ready.

The pane draws with the dgmo command. If you don't have it, the pane says so the first time and offers an Install dgmo button, which runs npm install -g @diagrammo/dgmo-cli for you (this needs Node.js). You can also run that command yourself beforehand.

Use it

You doWhat happens
Ask in plain words: "diagram how auth works here"Claude draws it with show_diagram; the pane opens with the picture
/dgmo-pane draw the release processThe same thing, as a command
/dgmo-pane docs/arch.dgmoShows that .dgmo file
Claude writes or edits any .dgmo fileThe pane redraws it after each save
/dgmo-paneOpens the pane

If a diagram does not parse, the pane keeps the last good picture and shows the error in red above it.

Where it draws: in Ghostty and kitty the diagram is a real image, and that works over ssh. The Claude desktop app and VS Code draw it as SVG. Other terminals show a one-line description instead.

Update

In a shell:

claude plugin marketplace update dgmo-pane
claude plugin update dgmo-pane@dgmo-pane

Then restart Claude Code.

Uninstall

claude plugin uninstall dgmo-pane@dgmo-pane
claude plugin marketplace remove dgmo-pane

Troubleshooting

You seeDo this
Unknown command: /dgmo-pane right after installingRestart Claude Code
"dgmo is not installed" in the panePress Install dgmo, or run npm install -g @diagrammo/dgmo-cli
"npm was not found" after pressing InstallInstall Node.js from nodejs.org, then press Install dgmo again
"dgmo is installed, but this session cannot find it"Restart Claude Code. If it persists, check that dgmo --version works in the shell you start claude from
The pane does not open by itselfIt waits until the terminal is at least 144 columns wide. Widen the window, or run /dgmo-pane to open it at any width
An empty box, or a one-line description where the picture should beYour terminal cannot draw images. Use Ghostty or kitty, or the Claude desktop app
Claude keeps getting DGMO syntax wrongRun dgmo install claude-code. It gives Claude the full DGMO language reference

The mod API is early access, so a Claude Code update can change how mods work. If the pane breaks after an update, please open an issue with your claude --version.

Develop

claude plugin validate .
claude plugin test .
claude --plugin-dir .      # run a session with this checkout loaded
Source 3 files
hooks/register.tsx 301 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Diagram, Setup } from '../types'
5import { dgmoError, fitCells, pngSize } from './layout'
6
7const PANE = 'dgmo-pane'
8const OUT_DIR = '/tmp/dgmo-pane'
9/** Svg's own bound on its markup. */
10const MAX_SVG = 131072
11/** Image's bound on inline bytes, 2 MiB decoded, as base64 characters. */
12const MAX_PNG_BASE64 = Math.floor((2 * 1024 * 1024 * 4) / 3)
13
14const TOOL = 'show_diagram'
15const TOOL_ID = 'mcp__dgmo-pane__show_diagram'
16const TOOL_DESCRIPTION = [
17  'Draws a diagram for the user in the dgmo pane beside the conversation.',
18  'Use it whenever the user asks to diagram, draw, chart or visualize something.',
19  'Pass the diagram as DGMO source, the language of the `dgmo` command: the first line names the chart type and a title,',
20  'then the content, e.g. "flowchart Login\n\n(Start) -> [Check password] -> (Signed in)".',
21  'Run `dgmo types` for every chart type. A parse error comes back as the result: fix the source and call again.',
22].join(' ')
23
24const diagram = atom({ plugin: 'dgmo-pane', key: 'diagram' } as const, null as Diagram | null)
25const setup = atom({ plugin: 'dgmo-pane', key: 'setup' } as const, { isDgmoMissing: false } as Setup)
26
27const PACKAGE = '@diagrammo/dgmo-cli'
28const INSTALL_COMMAND = `npm install -g ${PACKAGE}`
29const MISSING = `dgmo is not installed. The user can press Install dgmo in the pane, or run \`${INSTALL_COMMAND}\`.`
30
31const isDgmoMissing = ($: EngineInterface): Promise<boolean> =>
32  $.process.run(['dgmo', '--version']).then(
33    ran => ran.exitCode !== 0,
34    () => true,
35  )
36
37/** The pane's Install dgmo button: npm installs the CLI, then the last diagram draws again. */
38const installDgmo = async ($: EngineInterface): Promise<void> => {
39  await update($, setup, (old): Setup => ({ ...old, install: 'running', installError: undefined }))
40  const ran = await $.process.run(['npm', 'install', '-g', PACKAGE], { timeoutMs: 600_000 }).catch(() => undefined)
41  let installError: string | undefined
42  if (ran === undefined) {
43    installError = 'npm was not found. Install Node.js from nodejs.org, then press Install dgmo again.'
44  } else if (ran.exitCode !== 0) {
45    const last = ran.stderr.trim().split('\n').filter(line => line.trim() !== '').pop() ?? `exit ${ran.exitCode}`
46    installError = `npm install failed: ${last}`
47  } else if (await isDgmoMissing($)) {
48    installError = 'dgmo is installed, but this session cannot find it. Restart Claude Code.'
49  }
50  await update($, setup, (): Setup => ({
51    isDgmoMissing: installError !== undefined,
52    install: installError === undefined ? 'done' : 'failed',
53    installError,
54  }))
55  const last = await read($, diagram)
56  if (installError === undefined && last !== null) await render($, last.path, last.label)
57}
58
59/** Renders to temp files; only the newest render of a path writes the state. */
60let latest = 0
61
62const render = async ($: EngineInterface, path: string, label?: string): Promise<string | undefined> => {
63  const ticket = ++latest
64  await update($, diagram, old => ({
65    ...(old?.path === path ? old : { generation: 0 }),
66    path,
67    label,
68    isRendering: true,
69  }))
70
71  const stem = `${OUT_DIR}/${path.replace(/[^A-Za-z0-9._-]/g, '_')}`
72  let error: string | undefined
73  let size: { width: number; height: number } | undefined
74  let bytes: string | undefined
75  let markup: string | undefined
76  try {
77    await $.process.run(['mkdir', '-p', OUT_DIR])
78    const [png, svg] = await Promise.all([
79      $.process.run(['dgmo', path, '-o', `${stem}.png`, '--json', '--theme', 'dark'], { timeoutMs: 60_000 }),
80      $.process.run(['dgmo', path, '-o', `${stem}.svg`, '--json', '--theme', 'dark'], { timeoutMs: 60_000 }),
81    ])
82    error = dgmoError(png.stdout, png.stderr, png.exitCode)
83    if (error === undefined) {
84      const { base64 } = await $.fs.read(`${stem}.png`, { as: 'bytes' })
85      if (base64.length > MAX_PNG_BASE64) throw new Error('the PNG is over the 2 MiB an Image takes')
86      bytes = base64
87      size = pngSize(Uint8Array.fromBase64(base64))
88      if (dgmoError(svg.stdout, svg.stderr, svg.exitCode) === undefined) {
89        const text = await $.fs.read(`${stem}.svg`).catch(() => undefined)
90        markup = text !== undefined && text.length <= MAX_SVG ? text : undefined
91      }
92    }
93  } catch (err) {
94    error = (await isDgmoMissing($))
95      ? MISSING
96      : `could not show it: ${err instanceof Error ? err.message : String(err)}`
97  }
98  const isMissing = error === MISSING
99  if ((await read($, setup)).isDgmoMissing !== isMissing) {
100    await update($, setup, old => ({ ...old, isDgmoMissing: isMissing }))
101  }
102  if (ticket !== latest) return error
103
104  await update($, diagram, old => {
105    const base: Diagram = old?.path === path ? old : { path, label, generation: 0, isRendering: false }
106    if (error !== undefined || size === undefined || bytes === undefined) {
107      return { ...base, error: error ?? 'dgmo wrote no readable PNG', isRendering: false }
108    }
109
110    return {
111      path,
112      label,
113      png: bytes,
114      width: size.width,
115      height: size.height,
116      svg: markup,
117      generation: base.generation + 1,
118      isRendering: false,
119    }
120  })
121
122  return error
123}
124
125/** Inline diagrams get a fresh file each, so one never overwrites a picture still drawing. */
126let inline = 0
127
128/**
129 * Declares the command and the tool. `session.start` is too late for a mod
130 * installed mid-session (the session has already started), so the first
131 * event after a load also tries; both calls replace what they declared.
132 */
133let isDeclared = false
134const declare = async ($: EngineInterface): Promise<void> => {
135  if (isDeclared) return
136  isDeclared = true
137  try {
138    await $.command.register({
139      name: 'dgmo-pane',
140      description: 'Show a diagram in the dgmo pane: a .dgmo file, or describe what to draw',
141      argumentHint: '[file.dgmo | what to draw]',
142    })
143    await $.tool.register({
144      name: TOOL,
145      description: TOOL_DESCRIPTION,
146      inputSchema: {
147        type: 'object',
148        properties: {
149          source: { type: 'string', description: 'The whole diagram in DGMO.' },
150          title: { type: 'string', description: 'A short name for the pane header.' },
151        },
152        required: ['source'],
153      },
154    })
155  } catch {
156    isDeclared = false
157  }
158}
159
160export const register: Register = on => {
161  on('session.start', async ($, e, next) => {
162    await declare($)
163
164    return next(e)
165  })
166
167  on('prompt.submit', async ($, e, next) => {
168    await declare($)
169
170    return next(e)
171  })
172
173  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
174    void declare($)
175
176    return next(e)
177  })
178
179  on('command.run', { command: 'dgmo-pane' }, async ($, e) => {
180    await $.ui.open({ id: PANE, title: 'dgmo' }).catch(() => undefined)
181    const arg = e.args.trim()
182    if (arg === '') return { text: 'dgmo pane opened.' }
183
184    const stat = await $.fs.stat(arg, { resolve: true }).catch(() => undefined)
185    if (stat?.realPath !== undefined && stat.kind === 'file') {
186      const error = await render($, stat.realPath)
187
188      return { text: error === undefined ? `Rendered ${arg}.` : `dgmo: ${error}` }
189    }
190
191    // A command.run hook cannot submit: the prompt would wait on the dispatch
192    // this hook holds. A timer runs after it, once the session is idle.
193    $.clock.after(0, () => {
194      void $.prompt.submit({ text: `Draw this with the ${TOOL} tool: ${arg}`, asUser: true }).catch(() => undefined)
195    })
196
197    return { text: 'Asked Claude to draw it.' }
198  })
199
200  on('tool.call', { tool: TOOL_ID }, async ($, e) => {
201    const input = (e as unknown as { source?: unknown; title?: unknown })
202    if (typeof input.source !== 'string' || input.source.trim() === '') {
203      return { result: 'show_diagram needs `source`, the diagram in DGMO.', isError: true }
204    }
205    const title = typeof input.title === 'string' && input.title.trim() !== '' ? input.title.trim() : undefined
206    const path = `${OUT_DIR}/inline-${++inline}.dgmo`
207    const isWritten = await $.fs
208      .write(path, input.source.endsWith('\n') ? input.source : `${input.source}\n`)
209      .then(() => true, () => false)
210    if (!isWritten) return { result: `show_diagram could not write ${path}.`, isError: true }
211    void $.ui.open({ id: PANE, title: 'dgmo' }).catch(() => undefined)
212    const error = await render($, path, title)
213
214    return error === undefined
215      ? { result: 'The diagram is showing in the dgmo pane.' }
216      : { result: `dgmo could not render it: ${error}`, isError: true }
217  })
218
219  on('tool.call', async ($, e, next) => {
220    const ran = await next(e)
221    const isEdit = e.tool === 'Write' || e.tool === 'Edit'
222    if (!isEdit || ran.deny !== undefined || ran.isError === true) return ran
223    const path = e.file_path
224    if (!path.endsWith('.dgmo')) return ran
225
226    void $.ui.open({ id: PANE, title: 'dgmo' }).catch(() => undefined)
227    const error = await render($, path)
228    if (error === undefined) return ran
229
230    return { ...ran, context: [...(ran.context ?? []), `dgmo could not render ${path}: ${error}`] }
231  }).catch(($, e, next) => next(e))
232
233  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
234    const shown = await read($, diagram)
235    const name = shown?.label ?? shown?.path.split('/').pop() ?? ''
236    const status = shown === null ? '' : shown.isRendering ? ' · rendering…' : ''
237    const header = `${name}${status}`
238
239    const ready = await read($, setup)
240    if (ready.isDgmoMissing) {
241      const { Box, Text, Button } = $.ui.resolve(e)
242
243      return (
244        <Box flexDirection="column">
245          <Text bold>dgmo is not installed</Text>
246          <Text>The pane needs the dgmo command to draw diagrams.</Text>
247          {ready.install === 'running' ? (
248            <Text>Installing {PACKAGE}…</Text>
249          ) : (
250            <Button key="install" label="Install dgmo" hotkey="i" onPress={() => installDgmo($)} />
251          )}
252          {ready.installError !== undefined && <Text color="red">✖ {ready.installError}</Text>}
253          <Text>Or run it yourself: {INSTALL_COMMAND}</Text>
254        </Box>
255      )
256    }
257
258    if (e.surface === 'terminal') {
259      const { Box, Text, Image } = $.ui.resolve(e)
260      if (shown === null) {
261        return <Text dimColor>Nothing drawn yet. Ask Claude to diagram something, or run /dgmo-pane &lt;file or description&gt;.</Text>
262      }
263      const errorRows = shown.error === undefined ? 0 : 3
264      const room = {
265        columns: Math.max(1, (e.viewport?.columns ?? 80) - 2),
266        rows: Math.max(1, (e.viewport?.rows ?? 24) - 6 - errorRows),
267      }
268      const cells = shown.width && shown.height ? fitCells({ width: shown.width, height: shown.height }, room) : undefined
269
270      return (
271        <Box flexDirection="column">
272          <Text bold>{header}</Text>
273          {shown.error !== undefined && <Text color="red">✖ {shown.error}</Text>}
274          {shown.png !== undefined && cells !== undefined ? (
275            <Image
276              key="diagram"
277              source={{ png: shown.png }}
278              columns={cells.columns}
279              rows={cells.rows}
280              alt={`diagram from ${name}`}
281            />
282          ) : (
283            <Text dimColor>No picture yet.</Text>
284          )}
285        </Box>
286      )
287    }
288
289    const { Box, Text, Svg } = $.ui.resolve(e)
290    if (shown === null) return <Text>Nothing drawn yet. Ask Claude to diagram something.</Text>
291
292    return (
293      <Box flexDirection="column">
294        <Text bold>{header}</Text>
295        {shown.error !== undefined && <Text color="red">✖ {shown.error}</Text>}
296        {shown.svg !== undefined ? <Svg source={shown.svg} alt={`diagram from ${name}`} /> : <Text>No picture yet.</Text>}
297      </Box>
298    )
299  })
300}
301
hooks/layout.ts 44 lines
1/** Width and height of a PNG from its IHDR chunk, or undefined if not a PNG. */
2export const pngSize = (bytes: Uint8Array): { width: number; height: number } | undefined => {
3  const SIGNATURE = [0x89, 0x50, 0x4e, 0x47]
4  if (bytes.length < 24 || SIGNATURE.some((b, i) => bytes[i] !== b)) return undefined
5  const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
6
7  return { width: view.getUint32(16), height: view.getUint32(20) }
8}
9
10/** A terminal cell is about twice as tall as it is wide. */
11const CELL_ASPECT = 2
12const MAX_CELLS = 255
13
14/** Fits a picture into the pane, keeping its aspect ratio. */
15export const fitCells = (
16  size: { width: number; height: number },
17  room: { columns: number; rows: number },
18): { columns: number; rows: number } => {
19  const ratio = size.height / size.width / CELL_ASPECT
20  let columns = Math.min(MAX_CELLS, Math.max(1, room.columns))
21  let rows = Math.round(columns * ratio)
22  const maxRows = Math.min(MAX_CELLS, Math.max(1, room.rows))
23  if (rows > maxRows) {
24    rows = maxRows
25    columns = Math.max(1, Math.round(rows / ratio))
26  }
27
28  return { columns, rows: Math.max(1, rows) }
29}
30
31/** The error `dgmo --json` reports, or undefined on success. */
32export const dgmoError = (stdout: string, stderr: string, exitCode: number): string | undefined => {
33  try {
34    const report = JSON.parse(stdout) as { success?: boolean; error?: string }
35    if (report.success === true) return undefined
36    if (typeof report.error === 'string') return report.error
37  } catch {
38    // not JSON: fall through to the raw streams
39  }
40  const text = (stderr || stdout).trim()
41
42  return exitCode === 0 && text === '' ? undefined : text || `dgmo exited ${exitCode}`
43}
44
types/index.d.ts 34 lines
1export type Diagram = {
2  /** The .dgmo file shown, absolute. */
3  path: string
4  /** What the pane calls it; the file name when absent. */
5  label?: string
6  /** The last PNG that rendered, base64; sent inline so it draws over ssh too. */
7  png?: string
8  /** Its pixel size, read from the PNG header. */
9  width?: number
10  height?: number
11  /** The last SVG that rendered, for surfaces without Image. */
12  svg?: string
13  /** Counts the renders that succeeded. */
14  generation: number
15  /** The parser's error for the latest save; the picture is the last good one. */
16  error?: string
17  isRendering: boolean
18}
19
20export type Setup = {
21  /** The last render could not start `dgmo`, and `dgmo --version` failed too. */
22  isDgmoMissing: boolean
23  /** The pane's Install dgmo button: absent until pressed. */
24  install?: 'running' | 'done' | 'failed'
25  /** Why the install did not leave a working `dgmo`. */
26  installError?: string
27}
28
29declare module 'claude-code' {
30  interface PluginState {
31    'dgmo-pane': { diagram: Diagram | null; setup: Setup }
32  }
33}
34