SLOPSHOPPER

drawer

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

newrowsprocess
★ 2v0.4.1MITupdated 2026-10-07Sureffi/drawer
A shopper browsing a rack in a slop shop
README

drawer

check

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

a live session

install

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

the same graph, in box drawing

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.

settings

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.

theme

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.

how it works

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.

known wrong

  • A drawing the hook made is correct at the width it was drawn for. Claude Code re-wraps hook output on resize without asking again, so it shreds narrower and comes back when the window does. The module's drawings are drawn again at the new width.
  • Where a Claude Code build does not load hooks modules, a fork's or a subagent's view, --resume and a background job show every fence as source.
  • Under tmux or screen, a graph the module draws is box drawing: Claude Code draws no Image there. The hook's pictures still come through.
  • In a background job every graph is box drawing. The job's terminal is Claude Code's own, which draws no pictures.
  • macOS runs the whole test suite in CI, but a runner has no terminal, so the terminal path on a Mac is unverified. If it is wrong, drawings fall back to box drawing at 100 columns.
  • A session keeps the plugin version it started with. After an update, sessions already running draw with the old version until they end.
  • Installed mid-session and reloaded with /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.
  • Text is Go Mono. Characters it lacks, CJK and emoji among them, draw as the replacement character. Glyph drawings still show them.
  • A theme switch repaints at the next reply, not at the switch. A theme that changes the type changes the layout, and a picture that no longer fits its old cut is left as it was.
  • Claude Code's auto theme draws dark. The hook cannot ask the terminal.
  • The stock palettes are copied from Claude Code 2.1.257 and drift when Claude Code changes them.
  • A fence under a list item is drawn at the window's width less its indent. What Claude Code actually gives it is unmeasured.

developing

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.

license

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.

Source 1 files
hooks/render.tsx 288 lines
1// 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