SLOPSHOPPER

Shot Preview

Draws a thumbnail under a Claude reply that names a screenshot path, with a click to open it

newrowsprocess
v0.1.1MITupdated 2026-10-08mikhin/claude-shot-preview
A shopper browsing a rack in a slop shop
README

Claude Shot Preview

A Claude Code mod for the reply that says Screenshot: /var/folders/…/screenshot-1791286907506-0.jpg and leaves you to go find the file. It draws a thumbnail of that image right under the reply, with ↗ open to see it full size.

Install

Inside Claude Code, run:

/plugin marketplace add mikhin/claude-plugins
/plugin install shot-preview@mikhin
/reload-plugins

Requirements

  • A terminal that draws images through the kitty graphics protocol: Ghostty, kitty, WezTerm. Elsewhere the thumbnail is drawn as its path, and ↗ open still works.
  • macOS for JPEG thumbnails. The terminal draws PNG only, and the mod turns a JPEG into PNG with sips, which only macOS has. On Linux and Windows a PNG gets a thumbnail and a JPEG gets only ↗ open.
  • ↗ open is pressed with a click, which Claude Code reports in fullscreen mode.

What you see

● Screenshot: /var/folders/…/screenshot-1791286907506-0.jpg

  (thumbnail)
  ↗ open
  • The thumbnail is at most 36 columns by 10 rows and keeps the image's proportions.
  • A reply that names several images gets a thumbnail for each, side by side.
  • ↗ open opens the file in the system's default viewer.

How it works

  1. A session.append hook reads each reply of the main conversation as it is stored and finds paths ending in .png, .jpg or .jpeg: absolute, ~/-relative or C:\-style. A path in backticks is taken whole, so ` ~/Scans/passport (1).jpg ` works; a bare one ends at the first space.
  2. For each path that exists, it makes a preview: a PNG is used as is; a JPEG is turned into an 800 px PNG in $TMPDIR/claude-shots/, named by a hash of its path and modification time (macOS).
  3. A ui.render hook on the reply draws the reply as Claude Code does, then the thumbnails under it with Claude Code's Image element.

Only replies written in the current session get a thumbnail: the mod remembers previews for the session alone, so a resumed conversation shows its old paths as text. Subagent replies are skipped.

What it runs and sends

The mod runs when Claude Code stores a reply and when you press ↗ open.

  • Programs it starts, all on your machine:
  • uname, once, to tell macOS from Linux
  • mkdir -p $TMPDIR/claude-shots makes the folder previews go to (macOS)
  • sips -s format png -Z 800 <image> --out <preview>.png turns a JPEG into a PNG preview (macOS)
  • sips -g pixelWidth -g pixelHeight <preview>.png reads the preview's size (macOS)
  • on ↗ open: open <image> on macOS, xdg-open <image> on Linux, cmd /c start "" <image> on Windows
  • Files it reads and writes: it reads the OS, TMPDIR, HOME and USERPROFILE environment variables, checks that each path named in a reply exists and reads its modification time, and writes <hash>.png previews to $TMPDIR/claude-shots/. It never deletes them; macOS clears that folder.
  • What it changes in your messages: nothing. It adds thumbnails to how a reply is drawn; what Claude and you sent is stored unchanged.
  • What it sends over the network: nothing. The mod opens no connections.

License

MIT

Source 3 files
hooks/register.tsx 122 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Platform, Preview } from '../types'
5import { imagePaths } from './paths'
6
7const THUMB_COLUMNS = 36
8const THUMB_ROWS = 10
9
10const previews = atom({ plugin: 'shot-preview', key: 'previews' } as const, {})
11const platform = atom({ plugin: 'shot-preview', key: 'platform' } as const, null)
12
13type $ = EngineInterface
14
15const resolvePath = async ($: $, path: string): Promise<string> => {
16  if (!path.startsWith('~/')) return path
17  const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')) ?? ''
18  return `${home.replace(/[/\\]$/, '')}${path.slice(1)}`
19}
20
21const hash = (text: string): string => {
22  let h = 0x811c9dc5
23  for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 0x01000193)
24  return (h >>> 0).toString(16)
25}
26
27const pixels = (out: string, name: string): number => Number(new RegExp(`${name}: (\\d+)`).exec(out)?.[1] ?? 0)
28
29const detectPlatform = async ($: $): Promise<Platform> => {
30  const known = await read($, platform)
31  if (known) return known
32  const found: Platform =
33    (await $.env.get('OS')) === 'Windows_NT'
34      ? 'windows'
35      : (await $.process.run(['uname'])).stdout.trim() === 'Darwin'
36        ? 'mac'
37        : 'linux'
38  await update($, platform, () => found)
39  return found
40}
41
42const OPEN: Record<Platform, (path: string) => string[]> = {
43  mac: path => ['open', path],
44  linux: path => ['xdg-open', path],
45  windows: path => ['cmd', '/c', 'start', '""', path],
46}
47
48const toPreview = async ($: $, file: string): Promise<Preview> => {
49  const isPng = /\.png$/i.test(file)
50  if ((await detectPlatform($)) !== 'mac') return { file, png: isPng ? file : null, width: 0, height: 0 }
51  let png = file
52  if (!isPng) {
53    const tmp = ((await $.env.get('TMPDIR')) ?? '/tmp/').replace(/\/?$/, '/')
54    const cache = `${tmp}claude-shots`
55    const { mtimeMs } = await $.fs.stat(file)
56    png = `${cache}/${hash(`${file}:${mtimeMs}`)}.png`
57    if (!(await $.fs.exists(png))) {
58      await $.process.run(['mkdir', '-p', cache])
59      await $.process.run(['sips', '-s', 'format', 'png', '-Z', '800', file, '--out', png])
60    }
61  }
62  const { stdout } = await $.process.run(['sips', '-g', 'pixelWidth', '-g', 'pixelHeight', png])
63  return { file, png, width: pixels(stdout, 'pixelWidth'), height: pixels(stdout, 'pixelHeight') }
64}
65
66const fit = (width: number, height: number) => {
67  const [w, h] = width > 0 && height > 0 ? [width, height] : [16, 9]
68  const wide = { columns: THUMB_COLUMNS, rows: Math.round((THUMB_COLUMNS * h) / w / 2) }
69  const box = wide.rows <= THUMB_ROWS ? wide : { columns: Math.round((THUMB_ROWS * 2 * w) / h), rows: THUMB_ROWS }
70  return { columns: Math.max(1, box.columns), rows: Math.max(1, box.rows) }
71}
72
73export const register: Register = on => {
74  on('session.append', async ($, e, next) => {
75    const stored = await next(e)
76    if (e.door !== 'response' || e.agentId !== undefined) return stored
77    const text = e.message.content.map(block => (block.type === 'text' ? block.text : '')).join('\n')
78    const known = await read($, previews)
79    const fresh: [string, Preview][] = []
80    for (const path of imagePaths(text)) {
81      if (known[path]) continue
82      const file = await resolvePath($, path)
83      if (await $.fs.exists(file)) fresh.push([path, await toPreview($, file)])
84    }
85    if (fresh.length > 0) await update($, previews, all => ({ ...all, ...Object.fromEntries(fresh) }))
86    return stored
87  })
88
89  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
90    if (e.surface !== 'terminal') return next(e)
91    const known = await read($, previews)
92    const shots = imagePaths(e.props.text).flatMap(path => (known[path] ? [{ path, ...known[path] }] : []))
93    if (shots.length === 0) return next(e)
94
95    const { Box, Button, Image } = $.ui.resolve(e)
96
97    return (
98      <Box flexDirection="column">
99        {await next(e)}
100        <Box gap={2} flexWrap="wrap" marginLeft={2}>
101          {shots.map(shot => {
102            const room = fit(shot.width, shot.height)
103            return (
104              <Box key={shot.path} flexDirection="column">
105                {shot.png && <Image key={shot.path} source={{ file: shot.png, format: 'png' }} columns={room.columns} rows={room.rows} alt={shot.path} />}
106                <Button
107                  key={`open:${shot.path}`}
108                  plain
109                  dimColor
110                  onPress={async () => void (await $.process.run(OPEN[await detectPlatform($)](shot.file)))}
111                >
112                  ↗ open
113                </Button>
114              </Box>
115            )
116          })}
117        </Box>
118      </Box>
119    )
120  })
121}
122
hooks/paths.ts 13 lines
1const IMAGE = /\.(?:png|jpe?g)$/i
2const PATH_START = /^(?:~\/|\/|[A-Za-z]:\\)/
3const CODE_SPAN = /`([^`\n]+)`/g
4const BARE_PATH = /(?:~\/|[A-Za-z]:\\|\/)[^\s`'"()<>[\]]+\.(?:png|jpe?g)/gi
5
6export const imagePaths = (text: string): string[] => {
7  const quoted = [...text.matchAll(CODE_SPAN)]
8    .map(match => (match[1] ?? '').trim())
9    .filter(span => PATH_START.test(span) && IMAGE.test(span))
10  const bare = text.replace(CODE_SPAN, ' ').match(BARE_PATH) ?? []
11  return [...new Set([...quoted, ...bare])]
12}
13
types/index.d.ts 10 lines
1export type Preview = { file: string; png: string | null; width: number; height: number }
2
3export type Platform = 'mac' | 'linux' | 'windows'
4
5declare module 'claude-code' {
6  interface PluginState {
7    'shot-preview': { previews: Record<string, Preview>; platform: Platform | null }
8  }
9}
10