SLOPSHOPPER

image-mirror

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

newrows
v0.1.0MITupdated 2026-10-07voitta-ai/mods/image-mirror
A shopper browsing a rack in a slop shop
README

mods

Claude Code mods: plugins of function hooks that draw inside a terminal session.

ModWhat 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-mirrorDraws each PNG that Claude reads inline in the transcript, under its Read row.

Install

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.

Requirements

  • Pictures need a terminal with the kitty graphics protocol (kitty, Ghostty). Other terminals show the picture's alt text.
  • cw-chart runs aws cloudwatch get-metric-widget-image with your default AWS credentials. They need cloudwatch:GetMetricWidgetImage.

Developing

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.

Releasing

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.

License

MIT

Source 1 files
hooks/register.tsx 91 lines
1import 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