A ```dot fence in a reply is drawn in place, as the reply streams. Graphviz compiled in; nothing else running.

A ```dot fence in Claude Code becomes a drawing, in place, as the reply streams.

/plugin marketplace add sureffi/drawer /plugin install drawer@drawer
Then ask for a graph. The model writes DOT; you see the picture.
Linux and macOS. Nothing else to install: graphviz, the type and the painting are all in the binary.
The pictures need kitty or ghostty, the terminals that draw the placeholder cells a picture lives in — through tmux too. Anywhere else the graph is drawn in box drawing, which every terminal draws with its own hand.

A reply is drawn as you watch it stream. Claude Code shows replies in other places too: a fork's or a subagent's view, a session brought back with --resume, a background job. A hooks module draws those. Hooks modules are early access in Claude Code: where a build loads them, the module draws those screens; where it does not, the live turn still draws and those screens show the fence as source.
Three environment variables, set for claude in settings.json or the shell:
{ "env": { "DRAWER_RENDER": "cells", "DRAWER_THEME": "/home/me/.config/drawer/theme.dot", "DRAWER_TEE": "/home/me/drawer-deltas.jsonl" } }
DRAWER_RENDER forces a drawing: auto, pixels or cells.DRAWER_THEME names a theme file.DRAWER_TEE records every payload the hook receives, for drawer -deltas to replay.drawer -doctor prints what this terminal gets and why. The binary the last session started with is linked at ~/.claude/plugins/data/drawer-drawer/drawer.
Pictures use Claude Code's own theme: theme in ~/.claude/settings.json, custom themes included. Nodes take userMessageBackground and text, strokes claude, edge labels success, clusters inactive and subtle. What the model coloured itself stays as written. Switching theme repaints the pictures already drawn, at the next reply.
A theme file is DOT defaults, applied to every graph:
drawer -show-theme # the current theme as DOT; start a file from it drawer -theme t.dot -dot g.dot -png out.png # render a graph with it, no session needed
themes/tokyonight.dot and themes/tokyonight-day.dot are examples. A file replaces the theme; anything it leaves out is graphviz's default. fontname may point at a font file (.ttf, .otf, .ttc); otherwise text is set in the bundled Go Mono. Layout is measured in Courier, so use a monospace face.
Claude Code's MessageDisplay hook hands a command each piece of an assistant message before it is displayed and takes back replacement text. drawer replaces a ``dot fence with the drawing, in a fence labelled text: Claude Code 2.1.280 and later paints an unlabelled fence in one colour, and text` is a language its highlighter leaves alone. The transcript keeps the fence; only the display changes. Pieces arrive split wherever Claude Code splits them, one process each, so the fence is reassembled through a state file keyed by message id.
That hook is asked only about the live turn of the main conversation. hooks/render.tsx is a hooks module whose ui.render hook, where Claude Code loads it, is asked about every assistant message, on every screen Claude Code draws one. Where the hook has drawn, the module is handed the drawing, finds no fence, and passes it on. Anywhere else it cuts the message at its graph fences, lets Claude Code draw the prose, and puts each graph where its fence was, as drawer -element draws it. A change of width asks again, and the graph is drawn again for the new width.
SessionStart finds the binary or puts one in place, and hands the model one line: a ```dot fence draws in place. Without the line the model writes mermaid.
Layout is graphviz, compiled to WebAssembly and embedded (goccy/go-graphviz). A layout takes about a millisecond.
Pictures are painted in the binary from graphviz's own drawing operations, with the bundled Go Mono at the size that puts one glyph in one cell. The PNG goes to a temp file and one short kitty graphics escape names it, down the parent's tty, because Claude Code strips graphics escapes out of hook output. The terminal shows the image in Unicode placeholder cells, which ride through Claude Code as ordinary text. Under tmux the escape is wrapped in tmux's passthrough and allow-passthrough is turned on for the pane claude is in.
The module needs none of that: it hands Claude Code the PNG as an Image. Claude Code draws one in kitty or ghostty, outside tmux and screen, and never in a background job. Everywhere else the module draws box drawing.
A graph wider than the window is laid out top-down instead. One taller than 120 rows is not drawn; the source shows under a notice saying why. A fence labelled dot or graphviz is drawn, and so is an unlabelled fence that opens with digraph { or graph {. Other fences pass through untouched.
Where the terminal cannot show pictures the same graph is drawn in box drawing.
--resume and a background job show every fence as source.Image there. The hook's pictures still come through./reload-plugins, SessionStart does not fire, so the model is not told about the hook until a new session. A ```dot fence it writes anyway is drawn.auto theme draws dark. The hook cannot ask the terminal.git clone https://github.com/sureffi/drawer && cd drawer scripts/check.sh claude --plugin-dir /path/to/drawer
--plugin-dir loads the checkout as it stands, the module included, and in that session it stands in for an installed drawer. A checkout with bin/drawer built runs that binary, linked, so what scripts/check.sh rebuilds is what the next reply runs. One without it gets a binary in its data directory at SessionStart: the release binary downloaded, or a go build.
/plugin marketplace add on a checkout installs the release zip the marketplace names, not the checkout.
scripts/check.sh is every check in one command: build, vet, a vet cross-compiled for macOS, the import graph held to a table, the tests under -race, both rungs on a fixture, the theme files, the plugin manifests and wrapper, the module's door, the pixels rung to a PNG, and recorded hook streams replayed. Where claude is on the PATH it also validates the hooks and runs the module's laws in tests/ with claude plugin test. CI runs it on Linux and macOS on every push.
Offline, without a session:
./bin/drawer -dot FILE -size WxH -render cells # draw a file ./bin/drawer -dot FILE -png OUT [-cell 10x24] # the picture, to a file ./bin/drawer -deltas FILE -render cells # replay a recorded turn; nonzero if damaged ./bin/drawer -element -cols 80 < FILE # the module's drawing, as JSON ./bin/drawer -context # the line the model is handed ./bin/drawer -doctor # what this terminal gets and why
DRAWER_TEE records a live session as a -deltas fixture. A replay checks that prose outside a fence comes back byte for byte and every fence comes back untouched or as one drawn block that fits its width.
scripts/drawer holds the plugin's three doors: session and hook for the command hooks, element for the module. Each runs its own version's binary, the first that is there: a checkout's bin/drawer; the one shipped in the release zip; one downloaded at SessionStart, checked against the sha256 pinned in the script; or one built with go build. An organisation that can only point a marketplace at git gets the download. Every version shares the data directory, and a session keeps the version it started with, so a downloaded or built binary is named for its version, and no version runs another's.
scripts/release.sh VERSION builds the four binaries, pins their sums into the wrapper, zips the plugin, points the marketplace at the zip, commits, tags, pushes and creates the GitHub release.
The tree, one binary and nine packages, every import pointing down:
cmd/drawer/ the binary internal/drawer the flags, the hook wire, the ladder of rungs, the ledger internal/pixel the pixels rung: the themed drawing, the painter, the cut, the placeholders internal/theme Claude Code's theme, and a theme file internal/cells the cells rung: box drawing, edges routed on the grid, clusters framed internal/notice why there is no drawing, drawn internal/fence a fence in, a drawing or the same bytes out internal/layout graphviz: the one door, its drawing as data, the scale to cells internal/grid a row of terminal cells, and what text costs in one internal/term the parent's terminal: how big it is, where its output goes hooks/ the hook table, and the module that draws where no hook is asked scripts/ the hooks, the checks, the release themes/ two theme files to start from demo/ the session the README shows, and how it was made testdata/ a graph, and the recorded hook streams tests/ the module's laws
Windows does not build; the hook wire is Unix.
MIT. The embedded graphviz is under the Eclipse Public License 1.0 and the embedded Go Mono under the Go project's licence; a built binary redistributes both. NOTICE has the details.
hooks/render.tsx 288 lines1// render.tsx — the hooks module: a ```dot fence drawn on every screen
2// Claude Code draws a reply on.
3//
4// The MessageDisplay hook draws the live turn of the main thread and
5// nothing else. A fork's view, a subagent's, a transcript replayed by
6// --resume and a background job reattached are drawn from the transcript,
7// and no display hook is asked about them — measured on 2.1.283, each of
8// them showed the fence as source. ui.render is asked about every
9// assistant text block on every one of those screens.
10//
11// Where the display hook has already drawn — the main thread's own turn —
12// this hook is handed the drawing, not the fence (measured: the text it
13// gets is the hook's output), finds no fence in it, and hands the block on
14// untouched. So the two never draw the same fence, and the live turn keeps
15// drawing as it streams.
16//
17// A block with a graph in it is cut at its fences. The prose between them
18// is Claude Code's own drawing (next, with the text cut to that stretch);
19// each fence is what `drawer -element` makes of it: an Image where Claude
20// Code will draw pixels, Text rows of glyphs everywhere else. Anything
21// that fails leaves the block to Claude Code as it arrived.
22//
23// Function hooks are early access. A build that does not load hooks
24// modules runs the command hooks in hooks.json alone, and there they are
25// the whole plugin.
26
27import type { Elements, EngineInterface, Register, RenderChildren } from 'claude-code'
28
29// The fence rules are internal/fence's, for a block that has arrived
30// whole: a run of three or more backticks or tildes at the start of a
31// line, closed by a run of the same character at least as long with
32// nothing else on the line. A fence labelled dot or graphviz is a graph's;
33// an unlabelled one is when its first line opens a graph; any other is
34// somebody else's, and a ```dot quoted inside it is text.
35//
36// They are Go's character for character. A space is unicode.IsSpace's,
37// which strings.TrimSpace and strings.Fields cut at, and not JavaScript's
38// \s or trim(): U+0085 is one, U+FEFF is not. RE2's . is anything but a
39// newline, and its \s and \b are ASCII; its (?i) folds by Unicode's simple
40// folding, so ſ is an s, which /iu does too, in a class as well: the word
41// boundary after graph is read apart, without the flag. strings.ToLower
42// makes İ an i.
43const space = '\\t\\n\\v\\f\\r \\u0085\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000'
44const edgeSpace = new RegExp(`^[${space}]+|[${space}]+$`, 'g')
45const spaces = new RegExp(`[${space}]+`)
46const fenceRe = /^([ \t]*)(`{3,}|~{3,})([^\n]*)$/
47const graphKeyword = /^[\t\n\f\r ]*(strict[\t\n\f\r ]+)?(di)?graph/iu
48const graphRest = /^(?![0-9A-Za-z_])[^{]*\{/
49
50const trimSpace = (s: string) => s.replace(edgeSpace, '')
51
52// graphStart is a DOT graph's first line: the keyword, a name if any, and
53// the brace.
54function graphStart(line: string) {
55 const m = graphKeyword.exec(line)
56 return m !== null && graphRest.test(line.slice(m[0].length))
57}
58
59type Opener = { indent: string; run: string; info: string }
60
61function openerOf(line: string): Opener | null {
62 const m = fenceRe.exec(line.replace(/[ \t\r]+$/, ''))
63 if (!m) return null
64 const [, indent = '', run = '', rest = ''] = m
65 if (run[0] === '`' && rest.includes('`')) return null
66 const info = (trimSpace(rest).split(spaces)[0] ?? '').replace(/İ/g, 'i').toLowerCase()
67 return { indent, run, info }
68}
69
70function closes(f: Opener, line: string) {
71 const t = trimSpace(line)
72 return t.length >= f.run.length && [...t].every((c) => c === f.run[0])
73}
74
75// columnsOf is an indent's width as the display hook measures it,
76// grid.Cells: a space is a column and a tab is none.
77function columnsOf(indent: string) {
78 return [...indent].filter((c) => c === ' ').length
79}
80
81// A stretch of a block: prose, or a graph's fence with its source cut out.
82type Part = { raw: string; src?: string; indent?: number; closed?: boolean }
83
84// split cuts a block into stretches of prose and graphs, by line. Every
85// stretch keeps its lines exactly as they arrived, so a graph that will
86// not draw goes back into the prose around it byte for byte.
87function split(text: string): Part[] {
88 const lines = text.split('\n')
89 const parts: Part[] = []
90 let prose = 0
91 for (let i = 0; i < lines.length; ) {
92 const f = openerOf(lines[i] ?? '')
93 if (!f) {
94 i++
95 continue
96 }
97 let j = i + 1
98 while (j < lines.length && !closes(f, lines[j] ?? '')) j++
99 const closed = j < lines.length
100 const body = lines
101 .slice(i + 1, j)
102 .map((l) => (l.startsWith(f.indent) ? l.slice(f.indent.length) : l))
103 .join('\n')
104 const first = body.split('\n').find((l) => trimSpace(l) !== '') ?? ''
105 const ours = f.info === 'dot' || f.info === 'graphviz' || (f.info === '' && graphStart(trimSpace(first)))
106 const end = closed ? j + 1 : lines.length
107 if (ours) {
108 if (i > prose) parts.push({ raw: lines.slice(prose, i).join('\n') })
109 parts.push({ raw: lines.slice(i, end).join('\n'), src: body, indent: columnsOf(f.indent), closed })
110 prose = end
111 }
112 i = end
113 }
114 if (prose < lines.length) parts.push({ raw: lines.slice(prose).join('\n') })
115 return parts
116}
117
118// A span is a run of one style in a row of glyphs.
119type Span = { t: string; c?: string; d?: boolean }
120
121// Drawing is what `drawer element` answers for one fence: a picture, rows
122// of glyphs (a row with none is blank), or none, the fence left as it
123// arrived. A notice says why there is no drawing. failed is a run that
124// answered nothing, which is asked again.
125type Drawing =
126 | { kind: 'image'; png: string; columns: number; rows: number; alt?: string; notice?: boolean }
127 | { kind: 'text'; lines: (Span[] | null)[]; notice?: boolean }
128 | { kind: 'none'; failed?: boolean; notice?: undefined }
129
130// The drawings this module has asked for, newest last: a block is drawn
131// again on every scroll that moves it past an edge of the screen, and on
132// every width, and the answer does not change between them. The theme is
133// in the key, because a picture is painted in it, and so is the indent,
134// because the drawing is as wide as the screen less it.
135const drawn = new Map<string, Promise<Drawing>>()
136const drawnMost = 48
137
138function drawOnce($: EngineInterface, src: string, cols: number, indent: number, theme: string) {
139 const key = `${theme}\u0000${cols}\u0000${indent}\u0000${src}`
140 let p = drawn.get(key)
141 if (p) {
142 drawn.delete(key)
143 drawn.set(key, p)
144 return p
145 }
146 p = drawElement($, src, cols, indent).then((el) => {
147 if (el.kind === 'none' && el.failed) drawn.delete(key)
148 return el
149 })
150 drawn.set(key, p)
151 const oldest = drawn.keys().next().value
152 if (drawn.size > drawnMost && oldest !== undefined) drawn.delete(oldest)
153 return p
154}
155
156// drawElement runs the binary on one fence. A run that fails is failed,
157// and asked again on the next render: the wrapper exits non-zero where it
158// finds no binary, which the next session start puts in place. A run past
159// runMs is killed by the engine, which rejects with "still running after";
160// that is a layout too slow to wait for, and it is a none for the session.
161const runMs = 15000
162
163async function drawElement($: EngineInterface, src: string, cols: number, indent: number): Promise<Drawing> {
164 const argv = [`${$.plugin.root}/scripts/drawer`, 'element', String(cols), String(indent)]
165 try {
166 const { exitCode, stdout } = await $.process.run(argv, { stdin: src, timeoutMs: runMs })
167 if (exitCode !== 0) return { kind: 'none', failed: true }
168 return JSON.parse(stdout) as Drawing
169 } catch (err) {
170 const said = typeof err === 'object' && err !== null && 'message' in err ? String(err.message) : String(err)
171 return /still running after/.test(said) ? { kind: 'none' } : { kind: 'none', failed: true }
172 }
173}
174
175// The bullet that opens a reply is Claude Code's, drawn with the first
176// stretch of prose. A reply that opens with a graph has no prose there to
177// carry it, and gets Claude Code's glyph from here: ⏺ on macOS, ● elsewhere.
178let bullet: string | undefined
179
180async function bulletOf($: EngineInterface) {
181 if (bullet === undefined) {
182 try {
183 bullet = (await $.process.run(['uname', '-s'])).stdout.trim() === 'Darwin' ? '⏺' : '●'
184 } catch {
185 bullet = '●'
186 }
187 }
188 return bullet
189}
190
191// picture is one drawing, standing where the display hook's would: at the
192// text's own column, in the fence's indent, a row clear of the prose above.
193function picture(ui: Elements['terminal'], el: Exclude<Drawing, { kind: 'none' }>, indent: number, top: number) {
194 const { Box, Text, Image } = ui
195 const pad = { paddingLeft: indent, marginTop: top }
196 if (el.kind === 'image') {
197 return (
198 <Box {...pad}>
199 <Image source={{ png: el.png }} columns={el.columns} rows={el.rows} alt={el.alt ?? ''} />
200 </Box>
201 )
202 }
203 return (
204 <Box flexDirection="column" {...pad}>
205 {el.lines.map((line) => (
206 <Text wrap="truncate-end">
207 {!line || line.length === 0
208 ? ' '
209 : line.map((s) => {
210 const style: { color?: string; dimColor?: boolean } = {}
211 if (s.c) style.color = s.c
212 if (s.d) style.dimColor = true
213 return <Text {...style}>{s.t}</Text>
214 })}
215 </Text>
216 ))}
217 </Box>
218 )
219}
220
221export const register: Register = (on) => {
222 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
223 if (e.surface !== 'terminal') return next(e)
224 const text = e.props.text
225 if (!text.includes('```') && !text.includes('~~~')) return next(e)
226 const parts = split(text)
227 if (!parts.some((p) => p.src !== undefined)) return next(e)
228
229 const cols = e.viewport?.columns ?? 0
230 const theme = String((await $.settings.read()).theme ?? '')
231 const els = await Promise.all(parts.map((p) => (p.src === undefined ? null : drawOnce($, p.src, cols, p.indent ?? 0, theme))))
232
233 // A graph that will not draw is prose again, and so is a notice on a
234 // fence the reply never closed: that is a reply cut off, not a graph
235 // somebody got wrong.
236 const blocks: ({ prose: string; el?: undefined } | { el: Exclude<Drawing, { kind: 'none' }>; indent: number })[] = []
237 let prose: string[] = []
238 parts.forEach((p, i) => {
239 const el = els[i]
240 if (!el || el.kind === 'none' || (el.notice && !p.closed)) {
241 prose.push(p.raw)
242 return
243 }
244 blocks.push({ prose: prose.join('\n') })
245 blocks.push({ el, indent: p.indent ?? 0 })
246 prose = []
247 })
248 blocks.push({ prose: prose.join('\n') })
249 if (!blocks.some((b) => b.el)) return next(e)
250
251 const ui = $.ui.resolve(e)
252 const { Box, Text } = ui
253 const body: RenderChildren[] = []
254 let first = e.props.isFirstOfReply
255 let opened = false // a bullet is on screen, so what follows stands in its column
256 for (const b of blocks) {
257 if (b.el) {
258 body.push(picture(ui, b.el, b.indent, body.length === 0 ? 0 : 1))
259 continue
260 }
261 const stretch = b.prose.replace(/^(?:[ \t]*\n)+/, '').replace(/(?:\n[ \t]*)+$/, '')
262 if (stretch.trim() === '') continue
263 body.push(await next({ ...e, props: { ...e.props, text: stretch, isFirstOfReply: first && body.length === 0 } }))
264 if (first && body.length === 1) opened = true
265 }
266
267 if (!first) return <Box flexDirection="column">{body}</Box>
268 if (opened) {
269 return (
270 <Box flexDirection="column">
271 {body[0]}
272 <Box flexDirection="column" paddingLeft={2}>
273 {body.slice(1)}
274 </Box>
275 </Box>
276 )
277 }
278 return (
279 <Box flexDirection="row" alignItems="flex-start" marginTop={1}>
280 <Box minWidth={2}>
281 <Text color="text">{await bulletOf($)}</Text>
282 </Box>
283 <Box flexDirection="column">{body}</Box>
284 </Box>
285 )
286 })
287}
288