Draws each PNG Claude reads inline in the terminal transcript, under its Read row

Claude Code mods: plugins of function hooks that draw inside a terminal session.
| Mod | What it does |
|---|---|
cw-chart | /cw-chart <widget.json> opens a CloudWatch metric widget in a pane. The pane has 1h/6h/24h ranges, pan, refresh, and a strip you click to put a timestamp in the prompt. A widget with a fixed start/end opens as that window. Without them, or after you pick a range, it is live and re-fetches every minute. |
image-mirror | Draws each PNG that Claude reads inline in the transcript, under its Read row. |
At the prompt of a terminal session:
/plugin install cw-chart --marketplace voitta-ai/mods
/plugin install image-mirror --marketplace voitta-ai/mods
Answer y to add the marketplace, then choose a scope.
cw-chart runs aws cloudwatch get-metric-widget-image with your default AWS credentials. They need cloudwatch:GetMetricWidgetImage.Run a mod from its folder with claude --plugin-dir <mod folder>, or link it into the session's dev-mods folder for hot reload. Check a mod with claude plugin validate <mod folder>. CI runs the same check on every push and pull request.
A mod's version in its .claude-plugin/plugin.json is the cache key that installs use. A PR that changes a mod must bump that mod's version, or CI fails the PR. When the PR merges, CI tags <mod>-v<version> and creates a GitHub release. The release notes are the titles of the merged PRs, so write each PR title as a release note.
MIT
hooks/register.tsx 91 lines1import type { Register } from 'claude-code'
2
3// Image takes at most 2 MiB of decoded PNG: 4/3 of that in base64.
4const MAX_BASE64 = Math.floor((2 * 1024 * 1024 * 4) / 3)
5// A terminal cell is about twice as tall as it is wide.
6const CELL_ASPECT = 2
7// Columns the tool row's indent takes before the picture.
8const INDENT = 8
9const MAX_COLUMNS = 100
10const MAX_ROWS = 30
11
12type ReadImage = { type?: string; file?: { type?: string; base64?: string } }
13
14// The base64 PNG a Read call returned; undefined for any other tool or result.
15const pngOf = (tool: string, output: unknown) => {
16 const read = output as ReadImage | null | undefined
17 const base64 = read?.file?.base64
18 const isPng =
19 tool === 'Read' &&
20 read?.type === 'image' &&
21 read.file?.type === 'image/png' &&
22 base64 !== undefined &&
23 base64.length <= MAX_BASE64
24 const retval = isPng ? base64 : undefined
25 return retval
26}
27
28// Width and height from the PNG's IHDR chunk: bytes 16-23 of the file, the
29// first 32 base64 characters.
30const sizeOf = (base64: string) => {
31 const head = atob(base64.slice(0, 32))
32 const u32 = (at: number) => {
33 const value =
34 ((head.charCodeAt(at) << 24) |
35 (head.charCodeAt(at + 1) << 16) |
36 (head.charCodeAt(at + 2) << 8) |
37 head.charCodeAt(at + 3)) >>>
38 0
39 return value
40 }
41 const retval = { width: Math.max(1, u32(16)), height: Math.max(1, u32(20)) }
42 return retval
43}
44
45// The cell box that keeps the picture's aspect: as wide as the transcript
46// allows, at most MAX_COLUMNS by MAX_ROWS.
47const boxFor = (width: number, height: number, viewportColumns: number) => {
48 const wide = Math.max(10, Math.min(MAX_COLUMNS, viewportColumns - INDENT))
49 const tall = Math.max(1, Math.round((wide * height) / width / CELL_ASPECT))
50 const rows = Math.min(MAX_ROWS, tall)
51 const columns =
52 tall > MAX_ROWS ? Math.max(1, Math.round((rows * width * CELL_ASPECT) / height)) : wide
53 const retval = { columns, rows }
54 return retval
55}
56
57export const register: Register = on => {
58 // A run of reads folds into one count line. Unfold a run holding a PNG so
59 // each call draws as its own ToolUse row, which the hook below extends.
60 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
61 const hasPng =
62 e.surface === 'terminal' &&
63 e.props.calls.some(call => pngOf(call.tool, call.output) !== undefined)
64 const retval = await next(hasPng ? { ...e, props: { ...e.props, isExpanded: true } } : e)
65 return retval
66 })
67
68 // A Read row whose result is a PNG: the engine's row, the picture under it.
69 on('ui.render', { component: 'ToolUse', props: { tool: 'Read' } }, async ($, e, next) => {
70 const base64 = pngOf(e.props.tool, e.props.output)
71 let retval = await next(e)
72 if (e.surface === 'terminal' && base64 !== undefined) {
73 const { Box, Image } = $.ui.resolve(e)
74 const { width, height } = sizeOf(base64)
75 const { columns, rows } = boxFor(width, height, e.viewport?.columns ?? 80)
76 retval = (
77 <Box flexDirection="column">
78 {retval}
79 <Image
80 source={{ png: base64 }}
81 columns={columns}
82 rows={rows}
83 alt={`PNG ${width}x${height}`}
84 />
85 </Box>
86 )
87 }
88 return retval
89 })
90}
91